mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
198 lines
6.7 KiB
Go
198 lines
6.7 KiB
Go
// 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: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
|
||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
||
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
|
||
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
|
||
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
|
||
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", 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 := 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)
|
||
}
|
||
}
|