diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index 51d96b225..21cc226bf 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -151,12 +151,12 @@ func resolveFetchLang(runtime *common.RuntimeContext) string { // buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。 func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} { - mode := strings.TrimSpace(runtime.Str("scope")) + mode := effectiveFetchReadMode(runtime) if mode == "" || mode == "full" { return nil } ro := map[string]interface{}{"read_mode": mode} - if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" { + if v := effectiveFetchStartBlockID(runtime, mode); v != "" { ro["start_block_id"] = v } if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" { @@ -177,6 +177,72 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} { return ro } +func effectiveFetchReadMode(runtime *common.RuntimeContext) string { + mode := rawFetchReadMode(runtime) + if shouldUseDocSelectionAnchor(runtime, mode) { + if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" { + return "range" + } + } + return mode +} + +func rawFetchReadMode(runtime *common.RuntimeContext) string { + mode := strings.TrimSpace(runtime.Str("scope")) + if mode == "" { + return "full" + } + return mode +} + +func effectiveFetchStartBlockID(runtime *common.RuntimeContext, mode string) string { + if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" { + return v + } + if mode == "range" && shouldUseDocSelectionAnchor(runtime, rawFetchReadMode(runtime)) { + if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" { + return anchor + } + } + return "" +} + +func shouldUseDocSelectionAnchor(runtime *common.RuntimeContext, mode string) bool { + if runtime.Changed("start-block-id") || runtime.Changed("end-block-id") { + return false + } + if runtime.Changed("scope") { + return mode == "range" + } + return mode == "" || mode == "full" +} + +func docSelectionAnchorStartBlockID(runtime *common.RuntimeContext) string { + ref, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { + return "" + } + anchor, ok := parseDocShareSelectionAnchor(ref.Fragment) + if !ok { + return "" + } + return anchor +} + +func parseDocShareSelectionAnchor(raw string) (string, bool) { + value := strings.TrimSpace(raw) + value = strings.TrimPrefix(value, "#") + const prefix = "share-" + if !strings.HasPrefix(value, prefix) { + return "", false + } + anchorID := strings.TrimSpace(strings.TrimPrefix(value, prefix)) + if anchorID == "" { + return "", false + } + return prefix + anchorID, true +} + // 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. @@ -208,7 +274,7 @@ func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[str // validateReadModeFlags 客户端前置校验,服务端也会再校验一次。 func validateReadModeFlags(runtime *common.RuntimeContext) error { - mode := strings.TrimSpace(runtime.Str("scope")) + mode := effectiveFetchReadMode(runtime) if mode == "" || mode == "full" { return nil } @@ -227,7 +293,7 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error { case "outline": return nil case "range": - if strings.TrimSpace(runtime.Str("start-block-id")) == "" && + if effectiveFetchStartBlockID(runtime, mode) == "" && strings.TrimSpace(runtime.Str("end-block-id")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id").WithParams( errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"}, diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 9d681cf0b..d74e39926 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -180,6 +180,64 @@ func TestBuildFetchBodyIncludesReadOption(t *testing.T) { } } +func TestBuildFetchBodyUsesSelectionAnchorFragmentAsRangeStart(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse") + + body := buildFetchBody(runtime) + want := map[string]interface{}{ + "read_mode": "range", + "start_block_id": "share-CUE3d6Ykno2fkexEvt8cGF8Wnse", + } + if got := body["read_option"]; !reflect.DeepEqual(got, want) { + t.Fatalf("read_option = %#v, want %#v", got, want) + } +} + +func TestBuildFetchBodyExplicitFullIgnoresSelectionAnchorFragment(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse") + mustSetFetchFlag(t, runtime, "scope", "full") + + body := buildFetchBody(runtime) + if _, ok := body["read_option"]; ok { + t.Fatalf("did not expect read_option for explicit full scope: %#v", body["read_option"]) + } +} + +func TestBuildFetchBodyDoesNotAutoReadOrdinaryFragment(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#blk_plain") + + body := buildFetchBody(runtime) + if _, ok := body["read_option"]; ok { + t.Fatalf("did not expect read_option for ordinary URL fragment: %#v", body["read_option"]) + } +} + +func TestBuildFetchBodyDoesNotAutoReadUnsupportedSelectionAnchorFragments(t *testing.T) { + t.Parallel() + + for _, doc := range []string{ + "https://example.larksuite.com/wiki/wikcnToken#part-CUE3d6Ykno2fkexEvt8cGF8Wnse", + "https://example.larksuite.com/wiki/wikcnToken#share-", + } { + runtime := newFetchBodyTestRuntime(context.Background()) + mustSetFetchFlag(t, runtime, "doc", doc) + + body := buildFetchBody(runtime) + if _, ok := body["read_option"]; ok { + t.Fatalf("did not expect read_option for unsupported URL fragment %q: %#v", doc, body["read_option"]) + } + } +} + func TestBuildReadOptionModes(t *testing.T) { t.Parallel() @@ -375,6 +433,12 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) { "end-block-id": "blk_end", }, }, + { + name: "default scope with selection anchor fragment", + setFlags: map[string]string{ + "doc": "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse", + }, + }, { name: "keyword with keyword", setFlags: map[string]string{ @@ -884,6 +948,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) { func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd := &cobra.Command{Use: "+fetch"} + cmd.Flags().String("doc", "doxcnFetchDryRun", "") cmd.Flags().String("doc-format", fetchDefault("doc-format"), "") cmd.Flags().String("detail", fetchDefault("detail"), "") cmd.Flags().String("lang", fetchDefault("lang"), "") diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go index 713e7a50d..5c4ebfb3c 100644 --- a/shortcuts/doc/helpers.go +++ b/shortcuts/doc/helpers.go @@ -17,8 +17,9 @@ import ( const docsSceneContextKey = "lark_cli_docs_scene" type documentRef struct { - Kind string - Token string + Kind string + Token string + Fragment string } func parseDocumentRef(input string) (documentRef, error) { @@ -28,13 +29,13 @@ func parseDocumentRef(input string) (documentRef, error) { } if token, ok := extractDocumentToken(raw, "/wiki/"); ok { - return documentRef{Kind: "wiki", Token: token}, nil + return documentRef{Kind: "wiki", Token: token, Fragment: extractDocumentFragment(raw)}, nil } if token, ok := extractDocumentToken(raw, "/docx/"); ok { - return documentRef{Kind: "docx", Token: token}, nil + return documentRef{Kind: "docx", Token: token, Fragment: extractDocumentFragment(raw)}, nil } if token, ok := extractDocumentToken(raw, "/doc/"); ok { - return documentRef{Kind: "doc", Token: token}, nil + return documentRef{Kind: "doc", Token: token, Fragment: extractDocumentFragment(raw)}, nil } if strings.Contains(raw, "://") { return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc") @@ -62,6 +63,14 @@ func extractDocumentToken(raw, marker string) (string, bool) { return token, true } +func extractDocumentFragment(raw string) string { + idx := strings.Index(raw, "#") + if idx < 0 { + return "" + } + return strings.TrimSpace(raw[idx+1:]) +} + // doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns // the parsed "data" field from the standard Lark response envelope {code, msg, data}. // CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id diff --git a/shortcuts/doc/helpers_test.go b/shortcuts/doc/helpers_test.go index f249f9504..866597c5b 100644 --- a/shortcuts/doc/helpers_test.go +++ b/shortcuts/doc/helpers_test.go @@ -13,11 +13,12 @@ func TestParseDocumentRef(t *testing.T) { t.Parallel() tests := []struct { - name string - input string - wantKind string - wantToken string - wantErr string + name string + input string + wantKind string + wantToken string + wantFragment string + wantErr string }{ { name: "docx url", @@ -31,6 +32,13 @@ func TestParseDocumentRef(t *testing.T) { wantKind: "wiki", wantToken: "xxxxxx", }, + { + name: "wiki url with selection anchor", + input: "https://example.larksuite.com/wiki/xxxxxx#share-CUE3d6Ykno2fkexEvt8cGF8Wnse", + wantKind: "wiki", + wantToken: "xxxxxx", + wantFragment: "share-CUE3d6Ykno2fkexEvt8cGF8Wnse", + }, { name: "doc url", input: "https://example.larksuite.com/doc/xxxxxx", @@ -73,6 +81,9 @@ func TestParseDocumentRef(t *testing.T) { if got.Token != tt.wantToken { t.Fatalf("parseDocumentRef(%q) token = %q, want %q", tt.input, got.Token, tt.wantToken) } + if got.Fragment != tt.wantFragment { + t.Fatalf("parseDocumentRef(%q) fragment = %q, want %q", tt.input, got.Fragment, tt.wantFragment) + } }) } } diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 3ad4a3c27..478be8bb9 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -14,7 +14,7 @@ metadata: ```bash # 常用示例 -lark-cli docs +fetch --doc "文档URL或token" +lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取" lark-cli docs +create --content '
内容
' lark-cli docs +update --doc "文档URL或token" --command append --content '内容
' ``` diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index b0ffc87e1..947c689b0 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -17,8 +17,10 @@ lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3 # 按 block id 区间精读 -lark-cli docs +fetch --doc Z1Fj...tnAc \ - --scope range --start-block-id blkA --end-block-id blkB --detail with-ids +lark-cli docs +fetch --doc Z1Fj...tnAc --scope range --start-block-id blkA --end-block-id blkB --detail with-ids + +# URL 带 #share 选区锚点时自动局部读取 +lark-cli docs +fetch --doc 'docURL#share-anchor' # 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前) lark-cli docs +fetch --doc Z1Fj...tnAc \ diff --git a/tests/cli_e2e/docs/docs_fetch_dryrun_test.go b/tests/cli_e2e/docs/docs_fetch_dryrun_test.go index c1acda357..2a4c513bc 100644 --- a/tests/cli_e2e/docs/docs_fetch_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_fetch_dryrun_test.go @@ -43,6 +43,58 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) { } } +func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) { + setDocsDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", "https://example.larksuite.com/wiki/wikcnDryRun#share-CUE3d6Ykno2fkexEvt8cGF8Wnse", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/wikcnDryRun/fetch" { + t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.read_option.read_mode").String(); got != "range" { + t.Fatalf("read_mode=%q, want range\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" { + t.Fatalf("start_block_id=%q, want selection anchor\nstdout:\n%s", got, out) + } +} + +func TestDocsFetchDryRunUnsupportedSelectionAnchorFragmentStaysFull(t *testing.T) { + setDocsDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", "https://example.larksuite.com/wiki/wikcnDryRun#part-CUE3d6Ykno2fkexEvt8cGF8Wnse", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.body.read_option").Raw; got != "" { + t.Fatalf("read_option=%s, want omitted for unsupported selection anchor\nstdout:\n%s", got, out) + } +} + func setDocsDryRunEnv(t *testing.T) { t.Helper() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())