mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
235 lines
8.6 KiB
Go
235 lines
8.6 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/errs"
|
||
"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: "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: "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 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
|
||
}
|
||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||
}
|
||
|
||
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
|
||
}
|
||
if lang := resolveFetchLang(runtime); lang != "" {
|
||
body["lang"] = lang
|
||
}
|
||
|
||
detail := effectiveFetchDetail(runtime)
|
||
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
|
||
}
|
||
|
||
func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||
if runtime.Changed("lang") {
|
||
return strings.TrimSpace(runtime.Str("lang"))
|
||
}
|
||
if runtime.Config == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(string(runtime.Config.Lang))
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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.
|
||
func effectiveFetchDetail(runtime *common.RuntimeContext) string {
|
||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||
if format == "" || format == "xml" {
|
||
return detail
|
||
}
|
||
if detail == "with-ids" || detail == "full" {
|
||
return "simple"
|
||
}
|
||
return detail
|
||
}
|
||
|
||
func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[string]interface{}) string {
|
||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||
if format == "" || format == "xml" {
|
||
return ""
|
||
}
|
||
if detail != "with-ids" && detail != "full" {
|
||
return ""
|
||
}
|
||
warning := fmt.Sprintf("--detail %s is only supported with --doc-format xml; returning %s output and ignoring the unsupported detail option", detail, format)
|
||
appendDocWarning(data, warning)
|
||
return warning
|
||
}
|
||
|
||
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-before must be >= 0, got %d", v).WithParam("--context-before")
|
||
}
|
||
if v := runtime.Int("context-after"); v < 0 {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-after must be >= 0, got %d", v).WithParam("--context-after")
|
||
}
|
||
if v := runtime.Int("max-depth"); v < -1 {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-depth must be >= -1, got %d", v).WithParam("--max-depth")
|
||
}
|
||
|
||
switch mode {
|
||
case "outline":
|
||
return nil
|
||
case "range":
|
||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
|
||
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"},
|
||
errs.InvalidParam{Name: "--end-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"},
|
||
)
|
||
}
|
||
return nil
|
||
case "keyword":
|
||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keyword mode requires --keyword").WithParam("--keyword")
|
||
}
|
||
return nil
|
||
case "section":
|
||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "section mode requires --start-block-id").WithParam("--start-block-id")
|
||
}
|
||
return nil
|
||
default:
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope")
|
||
}
|
||
}
|