Compare commits

...

1 Commits

Author SHA1 Message Date
sunpeiyang.996
2487b4e1a6 feat: pass docs selection anchors to fetch 2026-07-08 23:39:30 +08:00
5 changed files with 274 additions and 16 deletions

View File

@@ -24,8 +24,8 @@ func v2FetchFlags() []common.Flag {
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
{Name: "start-block-id", Desc: "range/section anchor block id; range also accepts #share-xxx/#part-xxx selection anchors"},
{Name: "end-block-id", Desc: "range end block id; -1 means through document end; selection anchors are not supported"},
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
@@ -151,12 +151,12 @@ func resolveFetchLang(runtime *common.RuntimeContext) string {
// buildReadOption 拼装 read_option JSONfull/空模式返回 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,77 @@ 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 != "" {
if anchor, ok, _ := parseFetchSelectionAnchor(v, "--start-block-id"); ok {
return anchor
}
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, error) {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return "", nil
}
anchor, ok, err := parseFetchSelectionAnchor(ref.Fragment, "--doc")
if err != nil || !ok {
return "", err
}
return anchor, nil
}
func parseFetchSelectionAnchor(raw, param string) (string, bool, error) {
value := strings.TrimSpace(raw)
value = strings.TrimPrefix(value, "#")
for _, prefix := range []string{"share-", "part-"} {
if !strings.HasPrefix(value, prefix) {
continue
}
anchorID := strings.TrimSpace(strings.TrimPrefix(value, prefix))
if anchorID == "" {
return "", false, errs.NewValidationError(errs.SubtypeInvalidArgument, "selection anchor id is required after %s", prefix).WithParam(param)
}
return prefix + anchorID, true, nil
}
return "", false, nil
}
// 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 +279,10 @@ 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 err := validateFetchSelectionAnchorUsage(runtime, mode); err != nil {
return err
}
if mode == "" || mode == "full" {
return nil
}
@@ -227,7 +301,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"},
@@ -249,3 +323,42 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope")
}
}
func validateFetchSelectionAnchorUsage(runtime *common.RuntimeContext, mode string) error {
startBlockID := strings.TrimSpace(runtime.Str("start-block-id"))
endBlockID := strings.TrimSpace(runtime.Str("end-block-id"))
startAnchor, startIsAnchor, err := parseFetchSelectionAnchor(startBlockID, "--start-block-id")
if err != nil {
return err
}
_, endIsAnchor, err := parseFetchSelectionAnchor(endBlockID, "--end-block-id")
if err != nil {
return err
}
if endIsAnchor {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-block-id does not support selection anchors; pass #share/#part through --start-block-id with --scope range").WithParam("--end-block-id")
}
if !startIsAnchor {
_, _, err := parseFetchSelectionAnchorFromDoc(runtime)
return err
}
if mode != "range" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-block-id selection anchor %q requires --scope range", startAnchor).WithParam("--start-block-id")
}
if endBlockID != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-block-id selection anchor %q cannot be combined with --end-block-id", startAnchor).WithParams(
errs.InvalidParam{Name: "--start-block-id", Reason: "selection anchors define the complete selected range"},
errs.InvalidParam{Name: "--end-block-id", Reason: "remove --end-block-id when --start-block-id is a selection anchor"},
)
}
return nil
}
func parseFetchSelectionAnchorFromDoc(runtime *common.RuntimeContext) (string, bool, error) {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return "", false, nil
}
return parseFetchSelectionAnchor(ref.Fragment, "--doc")
}

View File

@@ -180,6 +180,63 @@ 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 TestBuildReadOptionNormalizesExplicitSelectionAnchorStart(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "scope", "range")
mustSetFetchFlag(t, runtime, "start-block-id", "#part-CUE3d6Ykno2fkexEvt8cGF8Wnse")
want := map[string]interface{}{
"read_mode": "range",
"start_block_id": "part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
}
if got := buildReadOption(runtime); !reflect.DeepEqual(got, want) {
t.Fatalf("buildReadOption() = %#v, want %#v", got, want)
}
}
func TestBuildReadOptionModes(t *testing.T) {
t.Parallel()
@@ -321,6 +378,31 @@ func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
},
wantParam: "--keyword",
},
{
name: "selection anchor cannot be end block",
setFlags: map[string]string{
"scope": "range",
"end-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
wantParam: "--end-block-id",
},
{
name: "selection anchor start cannot combine with end block",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"end-block-id": "blk_end",
},
wantParams: []string{"--start-block-id", "--end-block-id"},
},
{
name: "selection anchor start requires range",
setFlags: map[string]string{
"scope": "section",
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
wantParam: "--start-block-id",
},
{
name: "section needs start block",
setFlags: map[string]string{
@@ -375,6 +457,19 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
"end-block-id": "blk_end",
},
},
{
name: "range with selection anchor start",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
},
{
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 +979,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"), "")

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -43,6 +43,35 @@ 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 setDocsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())