mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
fix docs fetch and update ergonomics (#1466)
This commit is contained in:
@@ -38,9 +38,6 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
|
||||
}
|
||||
if err := validateFetchDetail(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
@@ -71,6 +68,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
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 {
|
||||
@@ -90,7 +90,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
|
||||
detail := runtime.Str("detail")
|
||||
detail := effectiveFetchDetail(runtime)
|
||||
switch detail {
|
||||
case "", "simple":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
@@ -146,17 +146,33 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
// 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 nil
|
||||
return detail
|
||||
}
|
||||
if detail == "with-ids" || detail == "full" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--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).WithParam("--detail")
|
||||
return "simple"
|
||||
}
|
||||
return nil
|
||||
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 客户端前置校验,服务端也会再校验一次。
|
||||
|
||||
@@ -5,9 +5,12 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -96,6 +99,126 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": detail,
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchWarning",
|
||||
"revision_id": float64(1),
|
||||
"content": "# hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchWarning",
|
||||
"--doc-format", "markdown",
|
||||
"--detail", "with-ids",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
warnings, _ := data["warnings"].([]interface{})
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"])
|
||||
}
|
||||
if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") {
|
||||
t.Fatalf("unexpected warning: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-pretty-warning"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchPrettyWarning/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchPrettyWarning",
|
||||
"revision_id": float64(1),
|
||||
"content": "# hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchPrettyWarning",
|
||||
"--doc-format", "markdown",
|
||||
"--detail", "full",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "# hello\n" {
|
||||
t.Fatalf("stdout = %q, want markdown content only", got)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: --detail full is only supported with --doc-format xml") ||
|
||||
!strings.Contains(got, "returning markdown output") ||
|
||||
!strings.Contains(got, "ignoring the unsupported detail option") {
|
||||
t.Fatalf("stderr missing downgrade warning: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -91,3 +91,22 @@ func buildDriveRouteExtra(docID string) (string, error) {
|
||||
}
|
||||
return string(extra), nil
|
||||
}
|
||||
|
||||
func appendDocWarning(data map[string]interface{}, warning string) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(warning) == "" {
|
||||
return
|
||||
}
|
||||
switch existing := data["warnings"].(type) {
|
||||
case []interface{}:
|
||||
data["warnings"] = append(existing, warning)
|
||||
case []string:
|
||||
data["warnings"] = append(existing, warning)
|
||||
case nil:
|
||||
data["warnings"] = []string{warning}
|
||||
default:
|
||||
data["warnings"] = []interface{}{existing, warning}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -88,3 +89,51 @@ func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) {
|
||||
t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDocWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appendDocWarning(nil, "ignored")
|
||||
|
||||
empty := map[string]interface{}{}
|
||||
appendDocWarning(empty, " ")
|
||||
if _, ok := empty["warnings"]; ok {
|
||||
t.Fatalf("blank warning should be ignored: %#v", empty)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
want interface{}
|
||||
}{
|
||||
{
|
||||
name: "missing warnings",
|
||||
data: map[string]interface{}{},
|
||||
want: []string{"new warning"},
|
||||
},
|
||||
{
|
||||
name: "string slice warnings",
|
||||
data: map[string]interface{}{"warnings": []string{"old warning"}},
|
||||
want: []string{"old warning", "new warning"},
|
||||
},
|
||||
{
|
||||
name: "interface slice warnings",
|
||||
data: map[string]interface{}{"warnings": []interface{}{"old warning"}},
|
||||
want: []interface{}{"old warning", "new warning"},
|
||||
},
|
||||
{
|
||||
name: "scalar warning",
|
||||
data: map[string]interface{}{"warnings": "old warning"},
|
||||
want: []interface{}{"old warning", "new warning"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
appendDocWarning(tt.data, "new warning")
|
||||
if got := tt.data["warnings"]; !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("warnings = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
|
||||
> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID,不要复用旧 ID。
|
||||
|
||||
### block_delete — 删除指定 block
|
||||
|
||||
```bash
|
||||
@@ -234,6 +236,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_replace 后重新获取 ID**:`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids`
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
|
||||
|
||||
@@ -77,6 +77,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
</ul>
|
||||
```
|
||||
|
||||
## 代码块
|
||||
- 代码块必须写成 `<pre lang="xxx" caption="可选说明"><code>代码内容</code></pre>`。
|
||||
- 不要将代码文本直接放在 `<pre>` 下;应放在内层 `<code>` 中。
|
||||
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
|
||||
Reference in New Issue
Block a user