// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package doc import ( "context" "fmt" "io" "strconv" "strings" "github.com/larksuite/cli/shortcuts/common" ) // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { return []common.Flag{ {Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}}, {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: "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"}, {Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"}, } } // validateFetchV2 is the Validate hook for the v2 fetch path. It runs before // --dry-run so that invalid input fails with a structured exit code (2) and // JSON envelope instead of slipping through dry-run as a "success". func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error { if err := validateDocsV2Only(runtime, "+fetch", docsFetchLegacyFlags()); err != nil { return err } if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { return common.FlagErrorf("invalid --doc: %v", err) } if err := validateFetchDetail(runtime); err != nil { return err } if err := validateReadModeFlags(runtime); err != nil { return err } return nil } func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) body := buildFetchBody(runtime) apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token) return common.NewDryRunAPI(). POST(apiPath). Desc("OpenAPI: fetch document"). Body(body). Set("document_id", ref.Token) } func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { ref, _ := parseDocumentRef(runtime.Str("doc")) apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token) body := buildFetchBody(runtime) data, err := doDocAPI(runtime, "POST", apiPath, body) if err != nil { return err } runtime.OutFormatRaw(data, nil, func(w io.Writer) { if doc, ok := data["document"].(map[string]interface{}); ok { if content, ok := doc["content"].(string); ok { fmt.Fprintln(w, content) } } }) return nil } func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{ "format": runtime.Str("doc-format"), } if v := runtime.Int("revision-id"); v > 0 { body["revision_id"] = v } detail := runtime.Str("detail") switch detail { case "", "simple": body["export_option"] = map[string]interface{}{ "export_block_id": false, "export_style_attrs": false, "export_cite_extra_data": false, } case "with-ids": body["export_option"] = map[string]interface{}{ "export_block_id": true, } case "full": body["export_option"] = map[string]interface{}{ "export_block_id": true, "export_style_attrs": true, "export_cite_extra_data": true, } } if ro := buildReadOption(runtime); ro != nil { body["read_option"] = ro } injectDocsScene(runtime, body) return body } // buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。 func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} { mode := strings.TrimSpace(runtime.Str("scope")) if mode == "" || mode == "full" { return nil } ro := map[string]interface{}{"read_mode": mode} if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" { ro["start_block_id"] = v } if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" { ro["end_block_id"] = v } if v := strings.TrimSpace(runtime.Str("keyword")); v != "" { ro["keyword"] = v } if v := runtime.Int("context-before"); v > 0 { ro["context_before"] = strconv.Itoa(v) } if v := runtime.Int("context-after"); v > 0 { ro["context_after"] = strconv.Itoa(v) } if v := runtime.Int("max-depth"); v >= 0 { ro["max_depth"] = strconv.Itoa(v) } return ro } // validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。 func validateFetchDetail(runtime *common.RuntimeContext) error { format := strings.TrimSpace(runtime.Str("doc-format")) detail := strings.TrimSpace(runtime.Str("detail")) if format == "" || format == "xml" { return nil } if detail == "with-ids" || detail == "full" { return common.FlagErrorf("--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) } return nil } // validateReadModeFlags 客户端前置校验,服务端也会再校验一次。 func validateReadModeFlags(runtime *common.RuntimeContext) error { mode := strings.TrimSpace(runtime.Str("scope")) if mode == "" || mode == "full" { return nil } if v := runtime.Int("context-before"); v < 0 { return common.FlagErrorf("--context-before must be >= 0, got %d", v) } if v := runtime.Int("context-after"); v < 0 { return common.FlagErrorf("--context-after must be >= 0, got %d", v) } if v := runtime.Int("max-depth"); v < -1 { return common.FlagErrorf("--max-depth must be >= -1, got %d", v) } switch mode { case "outline": return nil case "range": if strings.TrimSpace(runtime.Str("start-block-id")) == "" && strings.TrimSpace(runtime.Str("end-block-id")) == "" { return common.FlagErrorf("range mode requires --start-block-id or --end-block-id") } return nil case "keyword": if strings.TrimSpace(runtime.Str("keyword")) == "" { return common.FlagErrorf("keyword mode requires --keyword") } return nil case "section": if strings.TrimSpace(runtime.Str("start-block-id")) == "" { return common.FlagErrorf("section mode requires --start-block-id") } return nil default: return common.FlagErrorf("invalid --scope %q", mode) } }