mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
15 Commits
feat/sheet
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa7a92217 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
@@ -1355,6 +1371,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
|
||||
18
README.md
18
README.md
@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # Comma-separated values
|
||||
```
|
||||
|
||||
### JSON Output Contract
|
||||
|
||||
With `--format json` (the default), success and error envelopes are distinct.
|
||||
|
||||
Success goes to **stdout**, exit code `0`:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
Errors go to **stderr**, non-zero exit code:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||
|
||||
### Pagination
|
||||
|
||||
```bash
|
||||
|
||||
18
README.zh.md
18
README.zh.md
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
||||
--format csv # 逗号分隔值
|
||||
```
|
||||
|
||||
### JSON 输出契约
|
||||
|
||||
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||
|
||||
成功信封写入 **stdout**,退出码 0:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
错误信封写入 **stderr**,退出码非 0:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||
|
||||
### 分页
|
||||
|
||||
```bash
|
||||
|
||||
@@ -20,13 +20,28 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
cmd := NewCmdApi(f, runF)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestRootCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
// Should return an error
|
||||
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||
// It uses the same source as schema execution so completion candidates match
|
||||
// what `schema` can resolve.
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// runSchema resolves the path through the schema catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
catalog := registry.SchemaCatalog()
|
||||
if len(catalog.Services()) == 0 {
|
||||
// No embedded metadata and the runtime fallback is empty too: offline
|
||||
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||
}
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
|
||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
pm := "npm"
|
||||
install := updater.RunNpmInstall
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
pm = "pnpm"
|
||||
install = updater.RunPnpmInstall
|
||||
}
|
||||
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
npmResult := install(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
"hint": permissionHint(combined, pm),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
if hint := permissionHint(combined, pm); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
hint := verificationFailureHint(updater, latest, pm)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if skillsResult != nil {
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
skillsPM := "npx"
|
||||
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||
skillsPM = "pnpm dlx"
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
func permissionHint(pmOutput, pm string) string {
|
||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
if pm == "pnpm" {
|
||||
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||
}
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
if pm == "pnpm" {
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||
// and fails the test if the npm install path is invoked.
|
||||
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.PnpmInstallOverride = pnpmFn
|
||||
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||
t.Errorf("npm install must not be called for a pnpm install")
|
||||
return &selfupdate.NpmResult{}
|
||||
}
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "via pnpm") {
|
||||
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||
)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error exit")
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||
t.Errorf("expected failure envelope, got: %s", out)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pnpm add -g") {
|
||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||
}
|
||||
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
hint = permissionHint("EACCES: permission denied", "npm")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
if got := permissionHint("some other error", "npm"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||
`CategoryPolicy`.
|
||||
|
||||
### Success envelope (stdout)
|
||||
|
||||
For contrast: success responses render to **stdout** as an
|
||||
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": { "guid": "e297d3d0-..." },
|
||||
"meta": { "count": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
Consumers must branch on `ok` (or the process exit code). The success
|
||||
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||
will misclassify every successful call as a failure, which is
|
||||
especially dangerous around write commands (e.g. retrying a create that
|
||||
already succeeded).
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | When | Exit | Typed struct |
|
||||
|
||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
||||
// space→dot fallback covers domains where the two already coincide.
|
||||
func commandFormResolver(service string) func(string) string {
|
||||
byForm := map[string]string{}
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
|
||||
@@ -6,12 +6,10 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -40,8 +38,6 @@ const (
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
||||
return SourceValue + "/" + build.Version
|
||||
}
|
||||
|
||||
// AgentTraceValue returns a header-safe value from the
|
||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
||||
// surrounding whitespace, rejects values containing any Unicode
|
||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
||||
// for any invalid or empty value. Callers can use the result
|
||||
// directly in HTTP headers without further sanitisation.
|
||||
func AgentTraceValue() string {
|
||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
||||
if v == "" || len(v) > agentTraceMaxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BaseSecurityHeaders returns headers that every request must carry.
|
||||
func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
return h
|
||||
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTraceValue(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " ")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(envvars.CliAgentTrace, val)
|
||||
if got := AgentTraceValue(); got != val {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// Content safety scanning mode
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
|
||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
agentNameMaxLen = 128
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
func AgentName() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||
}
|
||||
|
||||
func AgentTrace() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||
}
|
||||
|
||||
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" || len(v) > maxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\x01injected")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||
t.Setenv(CliAgentName, longVal)
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTrace(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTrace(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " ")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(CliAgentTrace, longVal)
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(CliAgentTrace, val)
|
||||
if got := AgentTrace(); got != val {
|
||||
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@ package registry
|
||||
import "github.com/larksuite/cli/internal/apicatalog"
|
||||
|
||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||
// and schema lint.
|
||||
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||
func EmbeddedCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||
}
|
||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
||||
func RuntimeCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||
}
|
||||
|
||||
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||
// nothing to resolve; the merged view is the only data such binaries have.
|
||||
func SchemaCatalog() apicatalog.Catalog {
|
||||
if len(EmbeddedServicesTyped()) > 0 {
|
||||
return EmbeddedCatalog()
|
||||
}
|
||||
return RuntimeCatalog()
|
||||
}
|
||||
|
||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
)
|
||||
|
||||
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||
// restores them (with a full state reset) on cleanup.
|
||||
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
resetInit()
|
||||
orig := embeddedMetaJSON
|
||||
embeddedMetaJSON = data
|
||||
t.Cleanup(func() {
|
||||
waitBackgroundRefresh()
|
||||
embeddedMetaJSON = orig
|
||||
resetInit()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||
}
|
||||
if _, ok := c.Service("embedded_svc"); !ok {
|
||||
t.Fatal("expected embedded_svc from embedded metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||
// Init seeds via sync fetch.
|
||||
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write(testEnvelopeJSON("remote_svc"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
testMetaURL = ts.URL
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceRuntime {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||
}
|
||||
if _, ok := c.Service("remote_svc"); !ok {
|
||||
t.Fatal("expected remote_svc from runtime fallback")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
// After a CLI upgrade the embedded data can be fresher than an old
|
||||
// cache; an equal/older cache must not shadow it.
|
||||
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
}
|
||||
|
||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||
// marker, tagged with the given top-level data version and brand.
|
||||
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||
t.Helper()
|
||||
cDir := filepath.Join(dir, "cache")
|
||||
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := MergedRegistry{
|
||||
Version: version,
|
||||
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||
}
|
||||
data, _ := json.Marshal(reg)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||
mData, _ := json.Marshal(cm)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||
t.Helper()
|
||||
embedded, _ := json.Marshal(MergedRegistry{
|
||||
Version: embeddedVer,
|
||||
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||
})
|
||||
swapEmbeddedMeta(t, embedded)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||
InitWithBrand(core.BrandFeishu)
|
||||
}
|
||||
|
||||
func titleOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
svc, ok := ServiceTyped(name)
|
||||
if !ok {
|
||||
t.Fatalf("service %q not loaded", name)
|
||||
}
|
||||
return svc.Title
|
||||
}
|
||||
|
||||
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "2.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "2.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "not-a-semver")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||
// plugin builds without compiled meta_data.json still get remote data.
|
||||
initWithCache(t, "0.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||
// so cache overlay passes the version gate in InitWithBrand.
|
||||
func testRegistry(name string) MergedRegistry {
|
||||
return MergedRegistry{
|
||||
Version: "test-1.0",
|
||||
Version: "1.0.0",
|
||||
Services: []meta.Service{
|
||||
{
|
||||
Name: name,
|
||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
resetInit()
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||
resetInit()
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
if reg.Version != "test-1.0" {
|
||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
||||
if reg.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallPnpm
|
||||
InstallManual
|
||||
)
|
||||
|
||||
@@ -53,22 +54,32 @@ var (
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
PnpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
switch d.Method {
|
||||
case InstallNpm:
|
||||
return d.NpmAvailable
|
||||
case InstallPnpm:
|
||||
return d.PnpmAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||
switch {
|
||||
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||
return "installed via npm, but npm is not available in PATH"
|
||||
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||
return "installed via pnpm, but pnpm is not available in PATH"
|
||||
}
|
||||
return "not installed via npm"
|
||||
return "not installed via npm or pnpm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
@@ -101,17 +113,38 @@ type Updater struct {
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
backupCreated bool
|
||||
|
||||
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||
// binary was installed cannot change during a single process, so caching is
|
||||
// the correct semantics — and it is required for correctness: the update
|
||||
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||
// skills, so a re-detection at skills time could resolve a now-stale
|
||||
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||
// sequential.
|
||||
detectCache *DetectResult
|
||||
}
|
||||
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
return u.DetectOverride()
|
||||
}
|
||||
if u.detectCache != nil {
|
||||
return *u.detectCache
|
||||
}
|
||||
result := u.detectInstallMethod()
|
||||
u.detectCache = &result
|
||||
return result
|
||||
}
|
||||
|
||||
// detectInstallMethod performs the real (uncached) detection.
|
||||
func (u *Updater) detectInstallMethod() DetectResult {
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||
}
|
||||
_, npmErr := exec.LookPath("npm")
|
||||
_, pnpmErr := exec.LookPath("pnpm")
|
||||
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||
}
|
||||
|
||||
// detectFromResolved classifies the resolved binary path into an install
|
||||
// method and records package-manager availability. Split out from
|
||||
// DetectInstallMethod so the classification is unit-testable without touching
|
||||
// the filesystem or PATH.
|
||||
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||
method := InstallManual
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
method = InstallNpm
|
||||
}
|
||||
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
if containsPnpmMarker(resolved) {
|
||||
method = InstallPnpm
|
||||
} else {
|
||||
method = InstallNpm
|
||||
}
|
||||
}
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||
switch method {
|
||||
case InstallNpm:
|
||||
d.NpmAvailable = npmOnPath
|
||||
case InstallPnpm:
|
||||
d.PnpmAvailable = pnpmOnPath
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||
// whose resolved path runs through pnpm's home directory (e.g.
|
||||
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||
// followed by "store". Matching only these two shapes (rather than any bare
|
||||
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||
// classification is OS-independent and unit-testable anywhere.
|
||||
func containsPnpmMarker(p string) bool {
|
||||
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == ".pnpm" {
|
||||
return true
|
||||
}
|
||||
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||
if u.PnpmInstallOverride != nil {
|
||||
return u.PnpmInstallOverride(version)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
pnpmPath, err := exec.LookPath("pnpm")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
||||
return u.runSkillsCommand(args...)
|
||||
}
|
||||
|
||||
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||
// launcher selection is unit-testable on any platform.
|
||||
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||
if method == InstallPnpm && pnpmAvailable {
|
||||
r := args
|
||||
if len(r) > 0 && r[0] == "-y" {
|
||||
r = r[1:]
|
||||
}
|
||||
return "pnpm", append([]string{"dlx"}, r...)
|
||||
}
|
||||
return "npx", args
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
det := u.DetectInstallMethod()
|
||||
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||
binPath, err := exec.LookPath(launcher)
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPnpmMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Classic virtual-store layout (.pnpm segment).
|
||||
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/usr/local/bin/lark-cli", false},
|
||||
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||
// "pnpm" immediately followed by "store".
|
||||
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsPnpmMarker(c.path); got != c.want {
|
||||
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||
u := &Updater{DetectOverride: nil}
|
||||
u.DetectOverride = func() DetectResult {
|
||||
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||
}
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm {
|
||||
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||
}
|
||||
if !got.PnpmAvailable {
|
||||
t.Errorf("PnpmAvailable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||
}
|
||||
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||
t.Error("pnpm available should CanAutoUpdate")
|
||||
}
|
||||
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualReason_Pnpm(t *testing.T) {
|
||||
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||
t.Errorf("pnpm reason = %q", got)
|
||||
}
|
||||
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||
t.Errorf("manual reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||
return r
|
||||
}}
|
||||
got := u.RunPnpmInstall("2.0.0")
|
||||
if got.Err != nil {
|
||||
t.Fatalf("unexpected err: %v", got.Err)
|
||||
}
|
||||
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsInvocation(t *testing.T) {
|
||||
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||
cases := []struct {
|
||||
name string
|
||||
method InstallMethod
|
||||
pnpmAvailable bool
|
||||
args []string
|
||||
wantLauncher string
|
||||
wantRest []string
|
||||
}{
|
||||
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||
if gotLauncher != c.wantLauncher {
|
||||
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||
}
|
||||
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||
// stays consistent with what was detected — and reported — before the update.
|
||||
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
u := New()
|
||||
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||
u.detectCache = &cached
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.64",
|
||||
"version": "1.0.65",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
return attendees, nil
|
||||
}
|
||||
|
||||
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||
for _, attendee := range attendees {
|
||||
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
|
||||
if err == nil || !attendeesIncludeRoom(attendees) {
|
||||
return err
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
|
||||
return err
|
||||
}
|
||||
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
|
||||
}
|
||||
|
||||
var CalendarCreate = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+create",
|
||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
||||
"need_notification": true,
|
||||
})
|
||||
if err != nil {
|
||||
err = guideApprovalRoomReasonError(err, attendees)
|
||||
// Rollback: delete the event
|
||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
}
|
||||
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||
resultData["recurrence"] = recurrence
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||
var rows []map[string]interface{}
|
||||
|
||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||
type calendarEventTime struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventVChat mirrors the vchat block in the API response.
|
||||
type calendarEventVChat struct {
|
||||
VCType string `json:"vc_type,omitempty"`
|
||||
IconType string `json:"icon_type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MeetingURL string `json:"meeting_url,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventLocation mirrors the location block in the API response.
|
||||
type calendarEventLocation struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventReminder mirrors a reminder entry.
|
||||
type calendarEventReminder struct {
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// calendarEventOrganizer mirrors event_organizer.
|
||||
type calendarEventOrganizer struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventAttachment mirrors a single attachment entry.
|
||||
type calendarEventAttachment struct {
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
FileSize string `json:"file_size,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||
type calendarEventCheckInTime struct {
|
||||
TimeType string `json:"time_type,omitempty"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// calendarEventCheckIn mirrors event_check_in.
|
||||
type calendarEventCheckIn struct {
|
||||
EnableCheckIn bool `json:"enable_check_in"`
|
||||
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||
}
|
||||
|
||||
// calendarEvent mirrors the event object inside the API response.
|
||||
type calendarEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||
Location *calendarEventLocation `json:"location,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
IsException bool `json:"is_exception,omitempty"`
|
||||
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||
CreateTime string `json:"create_time,omitempty"`
|
||||
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||
}
|
||||
|
||||
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||
rawEvent, ok := data["event"]
|
||||
if !ok || rawEvent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||
}
|
||||
raw, err := json.Marshal(rawEvent)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var event calendarEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// buildCalendarEventOutput converts the typed event into the output map and
|
||||
// applies the four transformation rules:
|
||||
// 1. create_time -> RFC3339
|
||||
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||
// 3. flatten event into the top-level result
|
||||
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(startMap, "timestamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(endMap, "timestamp")
|
||||
}
|
||||
}
|
||||
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CalendarGet gets a single calendar event detail.
|
||||
var CalendarGet = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+get",
|
||||
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "event-id", Desc: "event ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
d := common.NewDryRunAPI()
|
||||
switch calendarId {
|
||||
case "":
|
||||
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||
calendarId = "<primary>"
|
||||
case "primary":
|
||||
calendarId = "<primary>"
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
return d.
|
||||
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||
Set("calendar_id", calendarId).
|
||||
Set("event_id", eventId)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarId == "" {
|
||||
calendarId = PrimaryCalendarIDStr
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event, err := parseCalendarEvent(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := buildCalendarEventOutput(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
summary, _ := out["summary"].(string)
|
||||
if summary == "" {
|
||||
summary = "(untitled)"
|
||||
}
|
||||
startMap, _ := out["start_time"].(map[string]interface{})
|
||||
endMap, _ := out["end_time"].(map[string]interface{})
|
||||
startStr, _ := startMap["datetime"].(string)
|
||||
if startStr == "" {
|
||||
startStr, _ = startMap["date"].(string)
|
||||
}
|
||||
endStr, _ := endMap["datetime"].(string)
|
||||
if endStr == "" {
|
||||
endStr, _ = endMap["date"].(string)
|
||||
}
|
||||
eventIdOut, _ := out["event_id"].(string)
|
||||
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||
row := map[string]interface{}{
|
||||
"event_id": eventIdOut,
|
||||
"summary": summary,
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
"free_busy_status": freeBusyStatus,
|
||||
"self_rsvp_status": selfRsvpStatus,
|
||||
}
|
||||
output.PrintTable(w, []map[string]interface{}{row})
|
||||
fmt.Fprintln(w)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_approval_room",
|
||||
"summary": "Approval Room",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_approval_room/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": codeInvalidParamsWithDetail,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/events/evt_approval_room",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Approval Room",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "omm_room1",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for approval room missing approval_reason, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok for %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidParameters {
|
||||
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
||||
}
|
||||
if p.Code != codeInvalidParamsWithDetail {
|
||||
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
|
||||
}
|
||||
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
||||
// primary error stays the add failure (classification preserved) and the Hint
|
||||
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
||||
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
func TestShortcuts_Returns10(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 10 {
|
||||
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarGet tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Daily Sync",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_001",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||
if strings.Contains(out, "\"event\": {") {
|
||||
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||
t.Errorf("expected event_id in output, got: %s", out)
|
||||
}
|
||||
// status=confirmed should be dropped
|
||||
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||
}
|
||||
// timestamp must be replaced with datetime
|
||||
if strings.Contains(out, "\"timestamp\":") {
|
||||
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"datetime\":") {
|
||||
t.Errorf("expected datetime in output, got: %s", out)
|
||||
}
|
||||
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_002",
|
||||
"summary": "Cancelled Meeting",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
"status": "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_002",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_003",
|
||||
"summary": "All-day",
|
||||
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_003",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--event-id", " ",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error for empty event-id")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Param != "--event-id" {
|
||||
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_404",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error when event field is missing")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
CalendarGet,
|
||||
}
|
||||
}
|
||||
|
||||
261
shortcuts/doc/docs_history.go
Normal file
261
shortcuts/doc/docs_history.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type docsHistoryListSpec struct {
|
||||
Doc documentRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type docsHistoryRevertSpec struct {
|
||||
Doc documentRef
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type docsHistoryRevertStatusSpec struct {
|
||||
Doc documentRef
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||
ref, err := parseDocumentRef(raw)
|
||||
if err != nil {
|
||||
return documentRef{}, err
|
||||
}
|
||||
if ref.Kind == "doc" {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||
}
|
||||
if version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||
}
|
||||
|
||||
var DocsHistoryList = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-list",
|
||||
Description: "List Lark document history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-list"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: list document history versions").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryListParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||
docsHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Lark document to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: revert document history").
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Body(docsHistoryRevertBody(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||
nil,
|
||||
docsHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevertStatus = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Lark document history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: get document history revert status").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryStatusParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||
docsHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
340
shortcuts/doc/docs_history_test.go
Normal file
340
shortcuts/doc/docs_history_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDocsHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "list rejects legacy doc URL",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||
param: "--doc",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-numeric history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-positive history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: DocsHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
|
||||
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != tt.category {
|
||||
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||
}
|
||||
if problem.Subtype != tt.subtype {
|
||||
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryList, []string{
|
||||
"+history-list",
|
||||
"--doc", "doxcnHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--doc", "doxcnHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": []interface{}{"blk_1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--doc", "doxcnHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "partial_failed" {
|
||||
t.Fatalf("status = %#v, want partial_failed", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
failed, _ := data["failed_block_tokens"].([]interface{})
|
||||
if len(failed) != 1 || failed[0] != "blk_1" {
|
||||
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestDocsHistoryURLValidationMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only supports docx documents") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||
case "update":
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +fetch --help"
|
||||
case "update":
|
||||
return "lark-cli docs +update --help"
|
||||
case "history-list":
|
||||
return "lark-cli docs +history-list --help"
|
||||
case "history-revert":
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
DocMediaInsert,
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
|
||||
@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
@@ -47,10 +47,12 @@ var DriveExport = common.Shortcut{
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
|
||||
{Name: "token", Desc: "source document token; bare tokens require --doc-type, while wiki tokens can use --doc-type wiki or fallback after file-token-invalid"},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
@@ -75,6 +77,7 @@ var DriveExport = common.Shortcut{
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -87,6 +90,7 @@ type ExportParams struct {
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
URL: p.URL,
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
@@ -106,6 +110,7 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
URL: runtime.Str("url"),
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
@@ -127,60 +132,93 @@ func validateExport(p ExportParams) error {
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if source.Type == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Resolve wiki node to underlying document token").
|
||||
Params(map[string]interface{}{"token": source.Token})
|
||||
spec.Token = "obj_token_from_step_0"
|
||||
if spec.DocType == "" {
|
||||
spec.DocType = "obj_type_from_step_0"
|
||||
}
|
||||
dry.Set("wiki_token", source.Token)
|
||||
}
|
||||
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
desc := "2-step orchestration: fetch docx markdown -> write local file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
|
||||
}
|
||||
dry.Desc(desc).
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
return dry
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
desc := "3-step orchestration: create export task -> limited polling -> download file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
dry.Desc(desc).
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Body(buildDriveExportTaskBody(spec)).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
if !source.WasURL {
|
||||
dry.Set("wiki_token_fallback", "if export task returns file token invalid, the CLI will resolve --token as a wiki node and retry once")
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
var wikiResolution driveExportWikiResolution
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
@@ -222,21 +260,23 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
ticket, resolvedSpec, resolution, err := createDriveExportTaskWithWikiFallback(ctx, runtime, spec, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
@@ -274,7 +314,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -284,7 +324,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
}, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -307,7 +347,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -357,7 +397,19 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
|
||||
if !resolution.Resolved {
|
||||
return out
|
||||
}
|
||||
out["wiki_token"] = resolution.WikiToken
|
||||
out["wiki_node"] = map[string]interface{}{
|
||||
"obj_token": resolution.ObjToken,
|
||||
"obj_type": resolution.ObjType,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -27,9 +27,16 @@ var (
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
|
||||
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
|
||||
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -37,6 +44,20 @@ type driveExportSpec struct {
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
type driveExportInputSource struct {
|
||||
Type string
|
||||
Token string
|
||||
Param string
|
||||
WasURL bool
|
||||
}
|
||||
|
||||
type driveExportWikiResolution struct {
|
||||
Resolved bool
|
||||
WikiToken string
|
||||
ObjToken string
|
||||
ObjType string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
@@ -127,45 +148,49 @@ func (s driveExportStatus) StatusLabel() string {
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
normalized, source, err := normalizeDriveExportSpecInput(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDriveExportNormalizedSpecForSource(normalized, source)
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, base for bitable, and pptx/pdf for slides")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "base" && spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
if err := validateDriveExportFormatCompatibility(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
@@ -173,15 +198,213 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
|
||||
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
|
||||
return nil
|
||||
}
|
||||
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported export format: --doc-type %s cannot be exported as %s",
|
||||
spec.DocType,
|
||||
spec.FileExtension,
|
||||
).
|
||||
WithParam("--file-extension").
|
||||
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
|
||||
}
|
||||
|
||||
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
|
||||
for _, allowed := range driveExportAllowedFileExtensions(docType) {
|
||||
if fileExtension == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveExportAllowedFileExtensions(docType string) []string {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc":
|
||||
return []string{"docx", "pdf"}
|
||||
case "docx":
|
||||
return []string{"docx", "pdf", "markdown"}
|
||||
case "sheet":
|
||||
return []string{"xlsx", "csv", "pdf"}
|
||||
case "bitable":
|
||||
return []string{"xlsx", "csv", "base", "pdf"}
|
||||
case "slides":
|
||||
return []string{"pptx", "pdf"}
|
||||
default:
|
||||
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
|
||||
if source.Type == "wiki" && spec.DocType == "" {
|
||||
return validateDriveExportPendingWikiSpec(spec, source)
|
||||
}
|
||||
return validateDriveExportNormalizedSpec(spec)
|
||||
}
|
||||
|
||||
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
|
||||
param := source.Param
|
||||
if param == "" {
|
||||
param = "--token"
|
||||
}
|
||||
if err := validate.ResourceName(spec.Token, param); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
|
||||
}
|
||||
if spec.OnlySchema && spec.FileExtension != "base" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --file-extension base, or remove --only-schema")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
|
||||
spec.URL = strings.TrimSpace(spec.URL)
|
||||
spec.Token = strings.TrimSpace(spec.Token)
|
||||
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
|
||||
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
|
||||
|
||||
if spec.Token == "" && spec.URL == "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
|
||||
}
|
||||
if spec.Token != "" && spec.URL != "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
|
||||
}
|
||||
|
||||
source := driveExportInputSource{
|
||||
Type: spec.DocType,
|
||||
Token: spec.Token,
|
||||
Param: "--token",
|
||||
}
|
||||
|
||||
rawInput := spec.Token
|
||||
inputParam := "--token"
|
||||
if spec.URL != "" {
|
||||
rawInput = spec.URL
|
||||
inputParam = "--url"
|
||||
}
|
||||
|
||||
if ref, ok := common.ParseResourceURL(rawInput); ok {
|
||||
refType := normalizeDriveExportDocType(ref.Type)
|
||||
source = driveExportInputSource{
|
||||
Type: refType,
|
||||
Token: ref.Token,
|
||||
Param: inputParam,
|
||||
WasURL: true,
|
||||
}
|
||||
spec.Token = ref.Token
|
||||
if refType != "wiki" {
|
||||
if !isDriveExportDocType(refType) {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
|
||||
inputParam,
|
||||
ref.Type,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type wiki conflicts with %s URL type %q",
|
||||
inputParam,
|
||||
refType,
|
||||
).
|
||||
WithParam("--doc-type").
|
||||
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != refType {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type %q conflicts with %s URL type %q",
|
||||
spec.DocType,
|
||||
inputParam,
|
||||
refType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
spec.DocType = refType
|
||||
} else if spec.DocType == "wiki" {
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
if strings.Contains(rawInput, "://") {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s URL %q: use a recognized Lark document URL",
|
||||
inputParam,
|
||||
rawInput,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.URL != "" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --url %q: use a recognized Lark document URL",
|
||||
spec.URL,
|
||||
).WithParam("--url")
|
||||
}
|
||||
if spec.DocType == "" {
|
||||
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
source.Type = "wiki"
|
||||
source.Token = spec.Token
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportDocType(docType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(docType)) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(docType))
|
||||
}
|
||||
}
|
||||
|
||||
func isDriveExportDocType(docType string) bool {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
@@ -193,8 +416,13 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -206,6 +434,99 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
|
||||
wikiToken = strings.TrimSpace(wikiToken)
|
||||
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
|
||||
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": wikiToken},
|
||||
nil,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
if !isDriveExportDocType(objType) {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
|
||||
objType,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != objType {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
|
||||
objType,
|
||||
spec.DocType,
|
||||
objType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
spec.Token = objToken
|
||||
spec.DocType = objType
|
||||
if err := validateDriveExportNormalizedSpec(spec); err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return spec, driveExportWikiResolution{
|
||||
Resolved: true,
|
||||
WikiToken: wikiToken,
|
||||
ObjToken: objToken,
|
||||
ObjType: objType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createDriveExportTaskWithWikiFallback(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return "", spec, resolution, err
|
||||
}
|
||||
ticket, err := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, err
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err == nil {
|
||||
return ticket, spec, driveExportWikiResolution{}, nil
|
||||
}
|
||||
if source.WasURL || !shouldRetryDriveExportAsWiki(err) {
|
||||
return "", spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
resolvedSpec, resolution, resolveErr := resolveDriveExportWikiSource(ctx, runtime, spec, spec.Token)
|
||||
if resolveErr != nil {
|
||||
return "", spec, driveExportWikiResolution{}, appendDriveExportRecoveryHint(
|
||||
err,
|
||||
fmt.Sprintf("export task rejected --token; attempted wiki node resolution also failed: %v", resolveErr),
|
||||
)
|
||||
}
|
||||
ticket, retryErr := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, retryErr
|
||||
}
|
||||
|
||||
func shouldRetryDriveExportAsWiki(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return false
|
||||
}
|
||||
return problem.Code == 1069914
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
|
||||
@@ -33,10 +33,36 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "docx url infers doc type",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki token with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "bare token requires doc type",
|
||||
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
|
||||
wantErr: "--doc-type is required",
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "only supports --doc-type docx",
|
||||
wantErr: "cannot be exported as markdown",
|
||||
},
|
||||
{
|
||||
name: "docx csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
@@ -72,17 +98,17 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
wantErr: "cannot be exported as base",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "only supports --doc-type slides",
|
||||
wantErr: "cannot be exported as pptx",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "slides only supports",
|
||||
wantErr: "cannot be exported as csv",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
@@ -113,6 +139,29 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveExportSpec(driveExportSpec{
|
||||
Token: "docx123",
|
||||
DocType: "docx",
|
||||
FileExtension: "csv",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported format error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if valErr.Param != "--file-extension" {
|
||||
t.Fatalf("param = %q, want --file-extension", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
|
||||
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
fetchStub := &httpmock.Stub{
|
||||
@@ -440,6 +489,76 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportURLInfersDocType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_url"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_url",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_url",
|
||||
"file_name": "url-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="url-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/docx/docxURL123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxURL123" {
|
||||
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -510,6 +629,318 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiURLResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki",
|
||||
"file_name": "wiki-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiNode123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki_token",
|
||||
"file_name": "wiki-token-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-token-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTokenFallbackAfterFileTokenInvalid(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
firstCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069914,
|
||||
"msg": "file token invalid",
|
||||
"log_id": "20260708000000TEST",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
|
||||
},
|
||||
}
|
||||
reg.Register(firstCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
retryCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_retry"},
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"docxResolved"`)
|
||||
},
|
||||
}
|
||||
reg.Register(retryCreate)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_retry",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_retry",
|
||||
"file_name": "retry-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_retry/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="retry-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(firstCreate.CapturedBody) == 0 {
|
||||
t.Fatal("first export task request was not sent with the original token")
|
||||
}
|
||||
var retryBody map[string]interface{}
|
||||
if err := json.Unmarshal(retryCreate.CapturedBody, &retryBody); err != nil {
|
||||
t.Fatalf("unmarshal retry export_tasks body: %v", err)
|
||||
}
|
||||
if retryBody["token"] != "docxResolved" {
|
||||
t.Fatalf("retry export_tasks body token = %v, want resolved docx token", retryBody["token"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected type mismatch error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
|
||||
t.Fatalf("error message = %q, want resolved type", valErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +29,8 @@ const (
|
||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||
|
||||
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
|
||||
)
|
||||
|
||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
|
||||
"pptx": {"slides"},
|
||||
}
|
||||
|
||||
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
type driveImportSpec struct {
|
||||
FilePath string
|
||||
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
return status, false, driveImportFailureError(status)
|
||||
}
|
||||
}
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
func driveImportFailureError(status driveImportStatus) *errs.APIError {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
|
||||
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
if code, ok := driveImportConcurrentOperationCode(msg); ok {
|
||||
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func driveImportConcurrentOperationCode(msg string) (int, bool) {
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
codeText := strconv.Itoa(code)
|
||||
for idx := strings.Index(msg, codeText); idx >= 0; {
|
||||
end := idx + len(codeText)
|
||||
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
|
||||
return code, true
|
||||
}
|
||||
|
||||
nextStart := idx + 1
|
||||
next := strings.Index(msg[nextStart:], codeText)
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
idx = nextStart + next
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isASCIIDigit(ch byte) bool {
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
t.Run(strconv.Itoa(code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
|
||||
}
|
||||
if problem.Code != code {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, code)
|
||||
}
|
||||
if !problem.Retryable {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if problem.Hint != driveImportConcurrentOperationHint {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg string
|
||||
}{
|
||||
{
|
||||
name: "ordinary failure",
|
||||
msg: "unsupported conversion",
|
||||
},
|
||||
{
|
||||
name: "longer numeric code containing known code",
|
||||
msg: "call CreateObjNode return error code, code: 12321401012, message:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: tt.msg,
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Code != 0 {
|
||||
t.Fatalf("code = %d, want 0", problem.Code)
|
||||
}
|
||||
if problem.Retryable {
|
||||
t.Fatal("expected non-concurrency failure to remain non-retryable")
|
||||
}
|
||||
if problem.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", problem.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
@@ -28,7 +28,12 @@ import (
|
||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||
|
||||
// Error codes from the minutes API.
|
||||
const minutesDetailNoReadPermissionCode = 2091005
|
||||
const (
|
||||
minutesDetailProcessingCode = 2091003
|
||||
minutesDetailNoReadPermissionCode = 2091005
|
||||
minutesDetailWaitTimeoutDefault = 300
|
||||
minutesDetailWaitIntervalDefault = 15
|
||||
)
|
||||
|
||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
|
||||
// minuteDetailItem represents a single minute detail result.
|
||||
type minuteDetailItem struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Title string `json:"title"`
|
||||
NoteID string `json:"note_id"`
|
||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
NextCommand string `json:"next_command,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||
waitReady := runtime.Bool("wait-ready")
|
||||
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||
|
||||
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
needKeyword := runtime.Bool("keyword")
|
||||
|
||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||
}
|
||||
} else {
|
||||
artifacts := make(map[string]any)
|
||||
if needSummary {
|
||||
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
return result
|
||||
}
|
||||
|
||||
func isMinutesDetailProcessingError(err error) bool {
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
|
||||
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
|
||||
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = minutesDetailWaitTimeoutDefault
|
||||
}
|
||||
if intervalSeconds <= 0 {
|
||||
intervalSeconds = minutesDetailWaitIntervalDefault
|
||||
}
|
||||
return timeoutSeconds, intervalSeconds
|
||||
}
|
||||
|
||||
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
data, err := call()
|
||||
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
|
||||
return data, err
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 || interval > remaining {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
|
||||
var flags []string
|
||||
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
|
||||
if runtime.Bool(flag) {
|
||||
flags = append(flags, "--"+flag)
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
|
||||
result.Status = "processing"
|
||||
result.Retryable = true
|
||||
result.Error = reason
|
||||
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
|
||||
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
|
||||
}
|
||||
|
||||
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
|
||||
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
|
||||
parts = append(parts, artifactFlags...)
|
||||
parts = append(parts, "--wait-ready")
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
|
||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
|
||||
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
|
||||
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
if r.Status == "processing" {
|
||||
row["status"] = "PROCESSING"
|
||||
} else {
|
||||
row["status"] = "FAIL"
|
||||
}
|
||||
row["error"] = r.Error
|
||||
if r.NextCommand != "" {
|
||||
row["next_command"] = r.NextCommand
|
||||
}
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
row["title"] = r.Title
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
func detailProcessingStub(path string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: path,
|
||||
Body: map[string]interface{}{
|
||||
"code": 2091003,
|
||||
"msg": "minute is processing",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesDetail.Mount(parent, f)
|
||||
parent.SetOut(stdout)
|
||||
parent.SetArgs([]string{"+detail", "--help"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("help failed: %v", err)
|
||||
}
|
||||
help := stdout.String()
|
||||
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["retryable"] != true {
|
||||
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["title"] != "Ready Metadata" {
|
||||
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||
}
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
arts, _ := m["artifacts"].(map[string]any)
|
||||
if arts == nil {
|
||||
t.Fatal("expected artifacts")
|
||||
}
|
||||
if arts["summary"] != "Test summary content" {
|
||||
t.Fatalf("summary = %v", arts["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
var callCount int
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||
Reusable: true,
|
||||
OnMatch: func(req *http.Request) { callCount++ },
|
||||
})
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||
if timeout != 9 || interval != 3 {
|
||||
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||
}
|
||||
}
|
||||
|
||||
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||
func chdirForDetailTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
dr := common.NewDryRunAPI()
|
||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
||||
}
|
||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
return common.NewDryRunAPI().
|
||||
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
|
||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
||||
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||
}
|
||||
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
||||
return body
|
||||
}
|
||||
|
||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
out["from_speaker_id"] = fromSpeakerID
|
||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
||||
out["from_speaker_input"] = fromSpeakerInput
|
||||
}
|
||||
} else {
|
||||
out["from_user_id"] = fromUserID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerInput != "" {
|
||||
return fromSpeakerInput
|
||||
}
|
||||
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerID != "" {
|
||||
return fromSpeakerID
|
||||
}
|
||||
|
||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"speakers": []interface{}{
|
||||
map[string]interface{}{
|
||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
||||
"name": "说话人1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||
// straight through without a second speakerlist call. If the code still
|
||||
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
FromSpeakerInput string `json:"from_speaker_input"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
||||
}
|
||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||
}
|
||||
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
if strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type minuteSpeaker struct {
|
||||
SpeakerID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
||||
}
|
||||
|
||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "speakers")
|
||||
speakers := make([]minuteSpeaker, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
||||
}
|
||||
return speakers, nil
|
||||
}
|
||||
|
||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var matches []minuteSpeaker
|
||||
for _, s := range speakers {
|
||||
if s.Name == name {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
||||
"no speaker named %q in minute transcript", name).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
||||
case 1:
|
||||
return matches[0].SpeakerID, nil
|
||||
default:
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.SpeakerID
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
||||
// The input may already be an opaque speaker_id, or a display name that requires
|
||||
// an internal speaker-list fetch.
|
||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, s := range speakers {
|
||||
if s.SpeakerID == input {
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
return resolveSpeakerIDByName(speakers, input)
|
||||
}
|
||||
|
||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID != "" {
|
||||
return "", fromUserID, nil
|
||||
}
|
||||
|
||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
||||
return fromSpeakerID, "", err
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
||||
speakers := []minuteSpeaker{
|
||||
{SpeakerID: "id_a", Name: "Alice"},
|
||||
{SpeakerID: "id_b", Name: "Bob"},
|
||||
{SpeakerID: "id_c", Name: "Alice"},
|
||||
}
|
||||
|
||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
||||
if err != nil || id != "id_b" {
|
||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
|
||||
outData := map[string]interface{}{
|
||||
"minute_url": minuteURL,
|
||||
}
|
||||
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||
outData["minute_token"] = minuteToken
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func extractUploadedMinuteToken(minuteURL string) string {
|
||||
u, err := url.Parse(minuteURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "minutes" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||
}
|
||||
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUploadedMinuteToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
|
||||
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
|
||||
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
|
||||
{name: "invalid", url: "://bad", want: ""},
|
||||
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
|
||||
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,18 +205,16 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
// Sheets backward-compatibility grouping.
|
||||
// Sheets backward-compatibility help grouping.
|
||||
//
|
||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||
// users whose lark-sheets skill predates the refactor keep working even after
|
||||
// upgrading only the binary. applySheetsCompatGroups tags each alias into a
|
||||
// dedicated deprecated cobra group. The refactored commands have been the
|
||||
// default for over a month, so `sheets --help` no longer lists these aliases:
|
||||
// sheetsUsageTemplate renders every group except the deprecated one. The
|
||||
// grouping is still applied for two reasons — the unknown-subcommand path
|
||||
// (cmd/root.go) keys off it to classify a mistyped legacy alias, and each
|
||||
// alias's own `sheets <alias> --help` still surfaces the "(→ +new-command)"
|
||||
// migration pointer appended below. The aliases stay fully executable.
|
||||
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||
// sort alphabetically into the same flat list as the current commands,
|
||||
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||
// obvious. Pure presentation — the aliases stay fully executable.
|
||||
const (
|
||||
sheetsCurrentGroupID = "sheets-current"
|
||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||
@@ -226,10 +224,9 @@ const (
|
||||
)
|
||||
|
||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in the alias's own
|
||||
// --help and reused by wrapSheetsBackwardDeprecation for the on-execution
|
||||
// _notice. Aliases absent from this map still land in the deprecated group,
|
||||
// just without a pointer, so a missing entry degrades gracefully.
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||
// absent from this map still land in the deprecated group, just without a
|
||||
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||
var sheetsAliasReplacement = map[string]string{
|
||||
// spreadsheet / sheet management
|
||||
"+create": "+workbook-create",
|
||||
@@ -282,43 +279,6 @@ var sheetsAliasReplacement = map[string]string{
|
||||
"+delete-float-image": "+float-image-delete",
|
||||
}
|
||||
|
||||
// sheetsUsageTemplate is cobra v1.10.2's stock usage template with a single
|
||||
// change: the group loop is guarded by {{if ne $group.ID "deprecated"}} so the
|
||||
// deprecated pre-refactor aliases are omitted from `sheets --help` altogether.
|
||||
// Everything else — current commands, ungrouped metaapi subcommands under
|
||||
// "Additional Commands", flags — renders exactly as cobra's default. Keep in
|
||||
// sync with cobra's defaultUsageTemplate on upgrade.
|
||||
var sheetsUsageTemplate = fmt.Sprintf(`Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{if ne $group.ID %q}}
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
|
||||
`, sheetsDeprecatedGroupID)
|
||||
|
||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
svc.AddGroup(
|
||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||
@@ -350,11 +310,6 @@ func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
c.GroupID = sheetsCurrentGroupID
|
||||
}
|
||||
}
|
||||
|
||||
// Refactored commands have been the default for over a month: drop the
|
||||
// deprecated group from `sheets --help` (see sheetsUsageTemplate). The
|
||||
// aliases remain grouped and executable, just no longer advertised here.
|
||||
svc.SetUsageTemplate(sheetsUsageTemplate)
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||
|
||||
@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
|
||||
cmd, _, err := program.Find([]string{"docs", name})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s shortcut: %v", name, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != name {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
|
||||
}
|
||||
if cmd.Flags().Lookup("api-version") != nil {
|
||||
t.Fatalf("docs %s should not expose --api-version", name)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
|
||||
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
@@ -511,11 +532,10 @@ func TestApplySheetsCompatGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: `sheets --help` must list refactored shortcuts under Available
|
||||
// Commands, but no longer advertise the deprecated pre-refactor aliases or the
|
||||
// deprecated group heading (sheetsUsageTemplate skips that group). The aliases
|
||||
// stay registered and executable — hidden from the parent listing, not removed.
|
||||
func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
|
||||
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||
// heading (telling users to update their skill) plus the per-alias migration
|
||||
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -531,25 +551,19 @@ func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{"Available Commands:", "+cells-get"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
for _, want := range []string{
|
||||
"Available Commands:",
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"(→ +cells-get)",
|
||||
"+write",
|
||||
"(→ +cells-set)",
|
||||
} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("sheets help still shows deprecated content %q:\n%s", unwanted, got)
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
if alias, _, ferr := sheetsCmd.Find([]string{"+read"}); ferr != nil || alias == nil {
|
||||
t.Fatalf("deprecated alias +read should stay registered, got err=%v cmd=%v", ferr, alias)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||
|
||||
@@ -5,7 +5,6 @@ package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -18,30 +17,20 @@ import (
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
localOfficeTokenPrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping the
|
||||
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officeTokenPrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
@@ -146,8 +135,7 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
|
||||
var v *errs.ValidationError
|
||||
if errors.As(wrapped, &v) {
|
||||
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||
return "", nil, v.WithParam("--file")
|
||||
}
|
||||
return "", nil, wrapped
|
||||
|
||||
@@ -332,21 +332,11 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maxBatchOperations caps how many sub-operations a single +batch-update may
|
||||
// carry. Every translated op (with its own cells/properties payload) is held in
|
||||
// the out slice at once before the whole batch is marshaled, so an unbounded
|
||||
// operation count is the same unbounded-materialization hazard as the fan-out
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
}
|
||||
if len(rawOps) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps))
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
|
||||
@@ -1,59 +1,4 @@
|
||||
{
|
||||
"+formula-verify": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
|
||||
},
|
||||
{
|
||||
"name": "max-locations",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max locations / samples per error type; default 20.",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "exit-on-error",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -80,32 +25,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+revision-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -154,14 +73,6 @@
|
||||
"desc": "Initial column count (default 20, max 200)",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
|
||||
"default": "sheet"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -308,7 +219,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
|
||||
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -604,7 +515,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1158,7 +1069,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
|
||||
"desc": "Group nesting level to ungroup; default 1 (outermost)",
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
@@ -1800,13 +1711,6 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -2835,7 +2739,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2855,13 +2759,6 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -2988,7 +2885,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3068,7 +2965,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3112,7 +3009,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3230,7 +3127,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -4169,7 +4066,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
|
||||
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -4850,172 +4747,5 @@
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-list": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "end-version",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "history-version-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "History version to revert to (from +history-list)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "transaction-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Async revert transaction id (from +history-revert)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+undo": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.",
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+changeset-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "start-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "required",
|
||||
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
|
||||
},
|
||||
{
|
||||
"name": "end-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
|
||||
"default": "-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -38,10 +38,9 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
@@ -166,7 +165,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
@@ -190,15 +188,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+changeset-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
|
||||
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
|
||||
},
|
||||
},
|
||||
"+chart-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -206,7 +195,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
@@ -416,7 +405,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -437,7 +426,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -474,7 +463,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
||||
@@ -537,7 +526,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -643,45 +632,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+formula-verify": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
||||
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
|
||||
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
|
||||
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
|
||||
},
|
||||
},
|
||||
"+history-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert-status": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -784,14 +734,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+revision-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+rows-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -826,7 +768,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -881,7 +822,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -995,21 +936,12 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+undo": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.", Default: "1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
@@ -53,7 +54,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) {
|
||||
flagSchemasOnce.Do(func() {
|
||||
var idx flagSchemaIndex
|
||||
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
|
||||
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
|
||||
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
|
||||
return
|
||||
}
|
||||
if idx.Flags == nil {
|
||||
|
||||
@@ -243,7 +243,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,20 +251,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// already reported above). Apply to both `number` and `integer` types.
|
||||
if num, ok := value.(float64); ok {
|
||||
if schema.Minimum != nil && num < *schema.Minimum {
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
@@ -357,7 +357,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
if schema.AdditionalProperties.Strict {
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
|
||||
@@ -281,18 +281,18 @@ func (m mapFlagView) validateRawTypes() error {
|
||||
// parse time; reject here too to keep batch/standalone parity.
|
||||
f, isNum := val.(float64)
|
||||
if !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
if math.Trunc(f) != f {
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
|
||||
}
|
||||
case "float64":
|
||||
if _, isNum := val.(float64); !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
case "bool":
|
||||
if _, isBool := val.(bool); !isBool {
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
@@ -45,8 +44,7 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
|
||||
// classification and only adds the domain's flag param.
|
||||
func sheetsInputStatError(flag string, err error) error {
|
||||
wrapped := common.WrapInputStatErrorTyped(err)
|
||||
var v *errs.ValidationError
|
||||
if errors.As(wrapped, &v) {
|
||||
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||
return v.WithParam(sheetsFlagParam(flag))
|
||||
}
|
||||
return wrapped
|
||||
@@ -54,30 +52,21 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
localOfficeTokenPrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officeTokenPrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
@@ -451,7 +440,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
@@ -462,9 +451,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
|
||||
@@ -215,8 +215,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
if borderStyles != nil {
|
||||
prototype["border_styles"] = borderStyles
|
||||
}
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var totalCells int64
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -226,13 +225,6 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
@@ -307,7 +299,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
clearType := normalizeClearType(runtime.Str("scope"))
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -390,10 +382,13 @@ var DropdownDelete = common.Shortcut{
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
// validateDropdownRanges enforces the shared maxBatchRanges cap.
|
||||
if _, err := validateDropdownRanges(runtime); err != nil {
|
||||
ranges, err := validateDropdownRanges(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ranges) > 100 {
|
||||
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -437,8 +432,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
}
|
||||
prototype = map[string]interface{}{"data_validation": validation}
|
||||
}
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var totalCells int64
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -448,13 +442,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
@@ -474,25 +461,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
|
||||
// ─── helpers resurrected from B3 (used here + future skills) ──────────
|
||||
|
||||
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
|
||||
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
|
||||
// the number of ops materialized into one batch_update.
|
||||
const maxBatchRanges = 100
|
||||
|
||||
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
|
||||
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
|
||||
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
|
||||
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
|
||||
// summing past it. totalCells is int64 to stay overflow-safe.
|
||||
func checkBatchStampBudget(totalCells int64) error {
|
||||
if totalCells > maxStampMatrixCells {
|
||||
return sheetsValidationForFlag("ranges",
|
||||
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
|
||||
totalCells, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDropdownRanges parses --ranges, requires every entry to carry a
|
||||
// sheet prefix, and returns the parsed list.
|
||||
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
@@ -522,9 +490,6 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if len(out) > maxBatchRanges {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_changeset ─────────────────────────────────────────────
|
||||
//
|
||||
// +changeset-get wraps the get_changeset read tool: fetch the raw changeset
|
||||
// (the list of edit actions) between two CS revisions of a spreadsheet, so a
|
||||
// human or reviewing agent can verify whether an AI edit actually fulfilled
|
||||
// the user's request.
|
||||
//
|
||||
// - --start-revision is the "before" baseline (required, >= 1).
|
||||
// - --end-revision is optional; when omitted it defaults to the latest
|
||||
// revision, returning every changeset from start up to now.
|
||||
// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap
|
||||
// is enforced server-side (sheet-facade-agg maxChangesetRevGap).
|
||||
|
||||
const changesetMaxRevGap = 20
|
||||
|
||||
// ChangesetGet fetches the raw changesets between two spreadsheet versions.
|
||||
var ChangesetGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+changeset-get",
|
||||
Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+changeset-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := changesetRevisions(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := changesetInput(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := changesetInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.",
|
||||
"The version gap is capped at 20 revisions (end - start + 1 <= 20).",
|
||||
},
|
||||
}
|
||||
|
||||
// changesetRevisions reads and validates the start / end revision flags.
|
||||
// end <= 0 means "not provided" (default to latest, resolved server-side); a
|
||||
// provided end must be >= start and within the 20-revision gap.
|
||||
func changesetRevisions(runtime flagView) (start int, end int, err error) {
|
||||
start = runtime.Int("start-revision")
|
||||
end = runtime.Int("end-revision")
|
||||
if start < 1 {
|
||||
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
|
||||
}
|
||||
if end > 0 {
|
||||
if end < start {
|
||||
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
|
||||
}
|
||||
if end-start+1 > changesetMaxRevGap {
|
||||
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
|
||||
}
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// changesetInput builds the get_changeset tool input. end_revision is only
|
||||
// sent when explicitly provided; otherwise the server defaults to latest.
|
||||
func changesetInput(runtime flagView) (map[string]interface{}, error) {
|
||||
start, end, err := changesetRevisions(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"start_revision": start,
|
||||
}
|
||||
if end > 0 {
|
||||
input["end_revision"] = end
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
|
||||
// is only sent when explicitly provided, otherwise the server defaults to the
|
||||
// latest revision.
|
||||
func TestChangesetGet_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "start + end bounded range",
|
||||
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
|
||||
wantInput: map[string]interface{}{
|
||||
"start_revision": float64(120),
|
||||
"end_revision": float64(135),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start only → end omitted (server defaults to latest)",
|
||||
args: []string{"--url", testURL, "--start-revision", "120"},
|
||||
wantInput: map[string]interface{}{
|
||||
"start_revision": float64(120),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChangesetGet, tt.args)
|
||||
got := decodeToolInput(t, body, "get_changeset")
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangesetGet_Validation covers the client-side revision guards, which
|
||||
// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20).
|
||||
func TestChangesetGet_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "start-revision must be >= 1",
|
||||
args: []string{"--url", testURL, "--start-revision", "0"},
|
||||
wantSub: "start-revision must be >= 1",
|
||||
},
|
||||
{
|
||||
name: "end before start rejected",
|
||||
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
|
||||
wantSub: "end-revision",
|
||||
},
|
||||
{
|
||||
name: "gap over 20 rejected",
|
||||
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
|
||||
wantSub: "version gap exceeds limit",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), c.wantSub) {
|
||||
t.Errorf("expected %q; got=%s|%s|%v", c.wantSub, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_formula_verify ───────────────────────────────────────
|
||||
//
|
||||
// Wraps verify_formula (read): scan formulas + cell error states across one
|
||||
// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! /
|
||||
// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors)
|
||||
// into a recalc.py-shaped JSON status report. The contract is the single
|
||||
// AI self-check entry point for the R10 "write → verify zero-error"
|
||||
// invariant — see canonical-spec/references/lark_sheet_formula_verify/.
|
||||
|
||||
// FormulaVerify wraps verify_formula. Sheet selection is optional (both
|
||||
// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans
|
||||
// every visible sub-sheet's current_region.
|
||||
var FormulaVerify = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+formula-verify",
|
||||
Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+formula-verify"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFormulaVerifySheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFormulaVerifyLimits(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
if runtime.Bool("exit-on-error") {
|
||||
return formulaVerifyExitOnError(out)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the
|
||||
// two multi-value selectors: at most one of --sheet-id / --sheet-name may be
|
||||
// non-empty (passing both is the high-frequency reflex confusion when the
|
||||
// caller cargo-cults the single-sheet shortcut signature). Both empty is the
|
||||
// documented "scan every visible sub-sheet" path. Control-char checks reuse
|
||||
// requireSheetSelector's logic on each item.
|
||||
func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error {
|
||||
ids := nonEmptySliceItems(runtime.StrSlice("sheet-id"))
|
||||
names := nonEmptySliceItems(runtime.StrSlice("sheet-name"))
|
||||
if len(ids) > 0 && len(names) > 0 {
|
||||
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "mutually exclusive"),
|
||||
sheetsInvalidParam("sheet-name", "mutually exclusive"),
|
||||
)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := requireSheetSelector(id, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
if err := requireSheetSelector("", name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or
|
||||
// negative flag value can't silently degrade the scan (the server-side
|
||||
// default would otherwise mask the typo).
|
||||
func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 {
|
||||
return sheetsValidationForFlag("max-locations", "--max-locations must be > 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nonEmptySliceItems trims and drops blanks from a repeated-flag value so
|
||||
// `--sheet-id ""` doesn't masquerade as a real entry.
|
||||
func nonEmptySliceItems(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formulaVerifyInput builds the verify_formula tool input map from CLI flags.
|
||||
// excel_id is required; everything else is optional per the schema.
|
||||
func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
}
|
||||
if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 {
|
||||
input["sheet_ids"] = ids
|
||||
} else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 {
|
||||
// The verify_formula schema only declares sheet_ids; the facade
|
||||
// accepts sheet_names as a parallel optional field so name-based
|
||||
// selection works without forcing the caller to pre-resolve. Mirrors
|
||||
// how the other read shortcuts pack both fields via
|
||||
// sheetSelectorForToolInput.
|
||||
input["sheet_names"] = names
|
||||
}
|
||||
if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 {
|
||||
input["ranges"] = ranges
|
||||
}
|
||||
if runtime.Changed("max-locations") {
|
||||
input["max_locations_per_error"] = runtime.Int("max-locations")
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// formulaVerifyExitOnError converts a verify_formula status into a non-zero
|
||||
// CLI exit when the caller passed --exit-on-error. status="errors_found"
|
||||
// is the only failure mode for this flag: "partial" means truncated but the
|
||||
// scanned slice is clean, and "success" is obviously clean. A missing /
|
||||
// unknown status is treated as a typed internal error because the tool's
|
||||
// schema guarantees the field and we don't want a silent zero-exit.
|
||||
func formulaVerifyExitOnError(out interface{}) error {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"verify_formula: missing status field in tool output")
|
||||
}
|
||||
status, _ := m["status"].(string)
|
||||
switch status {
|
||||
case "success", "partial":
|
||||
return nil
|
||||
case "errors_found":
|
||||
total, _ := util.ToFloat64(m["total_errors"])
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"verify_formula: %d formula error(s) detected; resolve and re-run", int(total)).
|
||||
WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success")
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"verify_formula: unexpected status %q", status)
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the
|
||||
// common input combinations: no selector (workbook-wide scan), explicit
|
||||
// sheet_ids, explicit ranges, and the optional max_locations_per_error
|
||||
// field. The test exercises the One-OpenAPI body
|
||||
// directly so the schema field names stay locked to the canonical
|
||||
// tool-schemas.json verify_formula node.
|
||||
func TestFormulaVerify_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "no selector — workbook-wide scan defaults",
|
||||
args: []string{"--url", testURL},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sheet_ids multi via repeat",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_ids": []interface{}{testSheetID, testSheetID2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sheet_names multi via comma",
|
||||
args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_names": []interface{}{"Sheet1", "Sheet2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ranges + max_locations",
|
||||
args: []string{
|
||||
"--url", testURL,
|
||||
"--range", "A1:Z200",
|
||||
"--range", "AA1:AZ100",
|
||||
"--max-locations", "5",
|
||||
},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"ranges": []interface{}{"A1:Z200", "AA1:AZ100"},
|
||||
"max_locations_per_error": float64(5),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, FormulaVerify, tt.args)
|
||||
got := decodeToolInput(t, body, "verify_formula")
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits
|
||||
// invoke_read (read scope) and not invoke_write — a scope mismatch here would
|
||||
// surface as a 403 from the gateway.
|
||||
func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL})
|
||||
if len(calls) == 0 {
|
||||
t.Fatalf("dry-run produced no api calls")
|
||||
}
|
||||
call, _ := calls[0].(map[string]interface{})
|
||||
url, _ := call["url"].(string)
|
||||
if !strings.HasSuffix(url, "/tools/invoke_read") {
|
||||
t.Errorf("verify_formula must hit invoke_read; got url=%q", url)
|
||||
}
|
||||
if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want {
|
||||
t.Errorf("url = %q, want %q", url, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector"
|
||||
// rule on the two multi-value flags. Both empty is the documented
|
||||
// workbook-wide scan path, so we only reject the both-supplied case.
|
||||
func TestFormulaVerify_RejectsBothSelectors(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--sheet-name", "Sheet1",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "mutually exclusive")
|
||||
gotParams := map[string]bool{}
|
||||
for _, p := range ve.Params {
|
||||
gotParams[p.Name] = true
|
||||
}
|
||||
if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] {
|
||||
t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like
|
||||
// `--max-locations 0`, which would otherwise be silently swallowed by the
|
||||
// "explicit value but unset" comparison in the input builder.
|
||||
func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "max-locations=0",
|
||||
args: []string{"--url", testURL, "--max-locations", "0"},
|
||||
want: "--max-locations must be > 0",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run"))
|
||||
requireValidation(t, err, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error
|
||||
// contract: success/partial → no error; errors_found → typed validation
|
||||
// error with SubtypeFailedPrecondition; missing or unknown status →
|
||||
// typed internal error so a silent zero-exit can never happen.
|
||||
func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success returns no error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil {
|
||||
t.Fatalf("success path returned err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial returns no error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil {
|
||||
t.Fatalf("partial path returned err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors_found yields failed_precondition with count", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError(map[string]interface{}{
|
||||
"status": "errors_found",
|
||||
"total_errors": float64(7),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "7 formula error") {
|
||||
t.Errorf("message %q must surface the error count", ve.Message)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Errorf("hint must be set so AI agents know to re-run after fixes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError("oops")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history (BE-1: +history-list) ─────────────────────────
|
||||
//
|
||||
// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI
|
||||
// invoke_read endpoint. The tool returns a sheet's version history. The
|
||||
// facade-agg tool already performs the response transform (minor_histories
|
||||
// trim / id → history_version_id / 4-field projection / RFC3339 create_time),
|
||||
// so the CLI passes the tool output straight through and does NOT re-implement
|
||||
// the transform client-side.
|
||||
//
|
||||
// History is workbook-level (no sheet selector), mirroring +workbook-info:
|
||||
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
|
||||
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
|
||||
//
|
||||
// Flags are declared inline here rather than via flagsFor(): the generated
|
||||
// flag_defs_gen.go / data/flag-defs.json are synced from sheet-skill-spec
|
||||
// (BE-3) and must not be hand-edited, so this hand-written shortcut owns its
|
||||
// own flag set. The two locator flags match +workbook-info's shape exactly.
|
||||
|
||||
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
|
||||
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
|
||||
// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required.
|
||||
func historyLocatorFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."},
|
||||
{Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."},
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryList wraps the history_list tool: list a spreadsheet's history
|
||||
// versions. Each item carries history_version_id / create_time / action /
|
||||
// all_block_revision (projected server-side). An empty sheet yields an empty
|
||||
// list and exit 0.
|
||||
//
|
||||
// Backward pagination: --end-version (optional int) maps to the tool's
|
||||
// `end_version` parameter. Omit on the first call to fetch the latest page.
|
||||
// On subsequent pages pass the previous response's next_end_version as
|
||||
// --end-version. The tool returns next_end_version + has_more only when
|
||||
// more history exists; both fields are absent at the earliest page.
|
||||
var HistoryList = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-list",
|
||||
Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: append(historyLocatorFlags(),
|
||||
common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."},
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "history_list", historyListInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Pass the tool output through verbatim — facade-agg already shaped it.
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Capture a history_version_id from the result to feed +history-revert.",
|
||||
"For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).",
|
||||
},
|
||||
}
|
||||
|
||||
// historyListInput composes the history_list tool input. --end-version is
|
||||
// optional: include it only when explicitly set so the server treats absence
|
||||
// as "first page (latest)".
|
||||
func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
in := map[string]interface{}{"excel_id": token}
|
||||
if runtime.Changed("end-version") {
|
||||
in["end_version"] = runtime.Int("end-version")
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history (BE-2: +history-revert / +history-revert-status) ──
|
||||
//
|
||||
// Two thin callTool wrappers over the facade-agg history tools:
|
||||
// - +history-revert → history_revert (write) — async revert
|
||||
// - +history-revert-status → history_revert_status (read) — poll outcome
|
||||
//
|
||||
// Both target a single history version via --history-version-id (the id
|
||||
// surfaced by +history-list). Revert is asynchronous: it returns a receipt /
|
||||
// transaction id that +history-revert-status then polls, distinguishing
|
||||
// in-progress / success / failure from the tool output (passed through
|
||||
// verbatim — no client-side shaping).
|
||||
//
|
||||
// ⚠️ Backend state: the facade-agg history_revert / history_revert_status
|
||||
// tools are registered but their downstream RPC wiring is a DEFERRED
|
||||
// follow-up; today they return a "not wired yet" guard error from the gateway,
|
||||
// which surfaces here as a normal tool error. These CLI shortcuts are correct
|
||||
// thin wrappers and will work end-to-end once the backend follow-up lands —
|
||||
// this is NOT a CLI blocker. See self_check.md.
|
||||
//
|
||||
// Flags are declared inline (historyLocatorFlags + history-version-id) rather
|
||||
// than via flagsFor(), because flag_defs_gen.go / data/flag-defs.json are
|
||||
// synced from sheet-skill-spec (BE-3) and must not be hand-edited.
|
||||
|
||||
// historyVersionIDFlag is the target-version selector shared by +history-revert.
|
||||
// Required at the cli surface (cobra MarkFlagRequired): a missing value yields
|
||||
// cobra's standard "required flag(s) \"history-version-id\" not set" message
|
||||
// before Validate runs. We still trim + reject control-chars in Validate to
|
||||
// reject empty strings ("--history-version-id "" "), which cobra accepts.
|
||||
func historyVersionIDFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "history-version-id",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Desc: "History version to act on (from +history-list).",
|
||||
}
|
||||
}
|
||||
|
||||
func historyRevertFlags() []common.Flag {
|
||||
return append(historyLocatorFlags(), historyVersionIDFlag())
|
||||
}
|
||||
|
||||
// validateHistoryVersionID enforces the required, control-char-clean
|
||||
// --history-version-id. Returns the trimmed value so callers reuse it.
|
||||
func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(runtime.Str("history-version-id"))
|
||||
if id == "" {
|
||||
return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func historyRevertInput(token, versionID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"history_version_id": versionID,
|
||||
}
|
||||
}
|
||||
|
||||
// transactionIDFlag is the async-revert receipt selector used by
|
||||
// +history-revert-status: the transaction_id returned by +history-revert (NOT a
|
||||
// history version id — the facade-agg status tool keys on transaction_id).
|
||||
// Required at the cli surface (cobra MarkFlagRequired) — same gating model as
|
||||
// historyVersionIDFlag. Validate still trims + rejects empty/control-char
|
||||
// values to catch the case where cobra accepts --transaction-id with an
|
||||
// empty-string value.
|
||||
func transactionIDFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "transaction-id",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Desc: "Async revert transaction id (from +history-revert).",
|
||||
}
|
||||
}
|
||||
|
||||
func historyRevertStatusFlags() []common.Flag {
|
||||
return append(historyLocatorFlags(), transactionIDFlag())
|
||||
}
|
||||
|
||||
// validateTransactionID enforces the required, trimmed --transaction-id and
|
||||
// returns it for reuse.
|
||||
func validateTransactionID(runtime *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(runtime.Str("transaction-id"))
|
||||
if id == "" {
|
||||
return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func historyRevertStatusInput(token, transactionID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"transaction_id": transactionID,
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryRevert wraps the history_revert tool (write): asynchronously revert a
|
||||
// spreadsheet to the given history version. --history-version-id is required
|
||||
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
|
||||
// Validate runs with cobra's standard "required flag(s)" error (which the
|
||||
// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still
|
||||
// trim + reject empty / control-char values in Validate to catch the
|
||||
// case where cobra accepts --history-version-id with an empty-string value.
|
||||
var HistoryRevert = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: historyRevertFlags(),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateHistoryVersionID(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
versionID := strings.TrimSpace(runtime.Str("history-version-id"))
|
||||
return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionID, err := validateHistoryVersionID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.",
|
||||
},
|
||||
}
|
||||
|
||||
// HistoryRevertStatus wraps the history_revert_status tool (read): poll the
|
||||
// outcome of a prior +history-revert. The tool output distinguishes
|
||||
// in-progress / success / failure and is passed through verbatim.
|
||||
var HistoryRevertStatus = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Poll the status of a history revert (in-progress / success / failure).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: historyRevertStatusFlags(),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateTransactionID(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
txnID := strings.TrimSpace(runtime.Str("transaction-id"))
|
||||
return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txnID, err := validateTransactionID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right
|
||||
// facade-agg tool, routes through the correct read/write invoke endpoint, and
|
||||
// builds the expected tool input (excel_id always; history_version_id for the
|
||||
// revert pair).
|
||||
func TestHistoryShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const versionID = "histVER123"
|
||||
const txnID = "txn-abc-123"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantPath string // invoke_read | invoke_write suffix
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+history-list via --url",
|
||||
sc: HistoryList,
|
||||
args: []string{"--url", testURL},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-list via --spreadsheet-token",
|
||||
sc: HistoryList,
|
||||
args: []string{"--spreadsheet-token", testToken},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-list paginates with --end-version",
|
||||
sc: HistoryList,
|
||||
args: []string{"--url", testURL, "--end-version", "12345"},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"end_version": float64(12345), // post-JSON-unmarshal numeric type
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-revert routes to invoke_write with version id",
|
||||
sc: HistoryRevert,
|
||||
args: []string{"--url", testURL, "--history-version-id", versionID},
|
||||
toolName: "history_revert",
|
||||
wantPath: "invoke_write",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"history_version_id": versionID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-revert-status routes to invoke_read with transaction id",
|
||||
sc: HistoryRevertStatus,
|
||||
args: []string{"--url", testURL, "--transaction-id", txnID},
|
||||
toolName: "history_revert_status",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"transaction_id": txnID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
callURL := dryRunFirstCallURL(t, tt.sc, tt.args)
|
||||
if !containsSuffix(callURL, tt.wantPath) {
|
||||
t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath)
|
||||
}
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a
|
||||
// missing required selector before any request is sent, with two distinct
|
||||
// gates by design:
|
||||
//
|
||||
// - +history-revert: --history-version-id is cobra-required (Required=true
|
||||
// in the flag def → MarkFlagRequired). cobra refuses the call before
|
||||
// Validate runs with a plain "required flag(s)" error; the cmd dispatcher
|
||||
// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2).
|
||||
// The test rig invokes the shortcut via cmd.Execute and observes the raw
|
||||
// cobra error directly (no dispatcher wrap), so we assert the cobra text
|
||||
// contract instead of the typed envelope.
|
||||
//
|
||||
// - +history-revert-status: --transaction-id is cobra-optional;
|
||||
// requiredness is enforced inside Validate so we still get a typed,
|
||||
// flag-tagged *errs.ValidationError with Param="--transaction-id".
|
||||
func TestHistoryRevert_MissingRequiredFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(HistoryRevert.Command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") {
|
||||
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(HistoryRevertStatus.Command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") {
|
||||
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first
|
||||
// api call's url, so tests can assert read vs. write endpoint routing.
|
||||
func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string {
|
||||
t.Helper()
|
||||
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
||||
}
|
||||
dryRun := decodeDryRunRaw(t, out)
|
||||
calls, ok := dryRun["api"].([]interface{})
|
||||
if !ok || len(calls) == 0 {
|
||||
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
|
||||
}
|
||||
call, _ := calls[0].(map[string]interface{})
|
||||
url, _ := call["url"].(string)
|
||||
return url
|
||||
}
|
||||
|
||||
func containsSuffix(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_revision_get ───────────────────────────────────────────
|
||||
//
|
||||
// RevisionGet is a read-only derivative over get_workbook_structure that
|
||||
// projects out only the document revision (version number). The backend
|
||||
// surfaces `revision` on every read/write tool response, so this shortcut
|
||||
// needs no dedicated backend tool — it issues the lightest existing read
|
||||
// (no range, just the workbook token) and narrows the payload to the single
|
||||
// field callers want.
|
||||
//
|
||||
// The revision is the anchor for recover / undo. Callers that have just run a
|
||||
// write already have it in that write's response; +revision-get is the
|
||||
// explicit, zero-side-effect way to fetch the current value on its own.
|
||||
var RevisionGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+revision-get",
|
||||
Description: "Get the spreadsheet's current document revision (version number).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+revision-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rev, err := projectRevision(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"revision": rev}, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"The revision is the version anchor for recover / undo; every read and write tool response already carries it.",
|
||||
},
|
||||
}
|
||||
|
||||
// projectRevision narrows a get_workbook_structure response to its `revision`
|
||||
// field. An absent revision means the backend predates revision injection on
|
||||
// read responses; surface that as an explicit error rather than emitting a
|
||||
// silent null.
|
||||
func projectRevision(out interface{}) (interface{}, error) {
|
||||
obj, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"get_workbook_structure returned non-object output")
|
||||
}
|
||||
rev, ok := obj["revision"]
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"get_workbook_structure did not return a revision (backend may not support it yet)")
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRevisionGetProjectRevision(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("extracts revision from a workbook-structure object", func(t *testing.T) {
|
||||
out := map[string]interface{}{
|
||||
"revision": float64(60),
|
||||
"sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}},
|
||||
}
|
||||
got, err := projectRevision(out)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != float64(60) {
|
||||
t.Errorf("revision = %v, want 60", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors when revision is absent", func(t *testing.T) {
|
||||
out := map[string]interface{}{"sheets": []interface{}{}}
|
||||
if _, err := projectRevision(out); err == nil {
|
||||
t.Error("expected an error when revision is missing, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors on a non-object output", func(t *testing.T) {
|
||||
if _, err := projectRevision("not-an-object"); err == nil {
|
||||
t.Error("expected an error for non-object output, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[
|
||||
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("range is empty")
|
||||
}
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) > 2 {
|
||||
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
|
||||
}
|
||||
dim1, idx1, err := parseA1Position(parts[0])
|
||||
if err != nil {
|
||||
@@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if dim1 != dim2 {
|
||||
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
|
||||
}
|
||||
if idx2 < idx1 {
|
||||
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("end position is before start")
|
||||
}
|
||||
return dim1, idx1, idx2, nil
|
||||
}
|
||||
@@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
|
||||
func parseA1Position(s string) (dimension string, idx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("position is empty")
|
||||
}
|
||||
isDigits := true
|
||||
isLetters := true
|
||||
@@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) {
|
||||
if isDigits {
|
||||
n, _ := strconv.Atoi(s)
|
||||
if n <= 0 {
|
||||
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
|
||||
}
|
||||
return "row", n - 1, nil
|
||||
}
|
||||
if isLetters {
|
||||
return "column", letterToColumnIndex(s), nil
|
||||
}
|
||||
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
|
||||
}
|
||||
|
||||
// columnIndexToLetter converts a 0-based column index to the spreadsheet
|
||||
|
||||
@@ -382,32 +382,6 @@ func (p *tablePayload) validate() error {
|
||||
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
|
||||
}
|
||||
}
|
||||
return p.checkCellBudget()
|
||||
}
|
||||
|
||||
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
|
||||
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
|
||||
// these cells come from the caller's own --sheets/--values payload rather than a
|
||||
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
|
||||
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
|
||||
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
|
||||
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
|
||||
// process before the first write leaves.
|
||||
const maxTablePutCells = 1_000_000
|
||||
|
||||
// checkCellBudget rejects a payload whose total materialized cell count across
|
||||
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
|
||||
// pathological row/column counts.
|
||||
func (p *tablePayload) checkCellBudget() error {
|
||||
var total int64
|
||||
for i := range p.Sheets {
|
||||
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
|
||||
}
|
||||
if total > maxTablePutCells {
|
||||
return common.ValidationErrorf(
|
||||
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
|
||||
total, maxTablePutCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -669,8 +643,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matrix, err = applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
if err != nil {
|
||||
if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -682,15 +655,11 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// styles can pad the matrix wider than the declared columns (cell_styles on
|
||||
// blank cells past the data extent), so the written width comes from the
|
||||
// padded matrix, not ncols.
|
||||
writeCols := len(matrix[0])
|
||||
startCol := columnIndexToLetter(col0)
|
||||
endCol := columnIndexToLetter(col0 + writeCols - 1)
|
||||
endCol := columnIndexToLetter(col0 + ncols - 1)
|
||||
allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite
|
||||
|
||||
rowsPerBatch := tablePutMaxCellsPerWrite / writeCols
|
||||
rowsPerBatch := tablePutMaxCellsPerWrite / ncols
|
||||
if rowsPerBatch < 1 {
|
||||
rowsPerBatch = 1
|
||||
}
|
||||
@@ -724,7 +693,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
"sheet_id": sheetID,
|
||||
"range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)),
|
||||
"data_rows": len(s.Rows),
|
||||
"columns": writeCols,
|
||||
"columns": ncols,
|
||||
"writes": writes,
|
||||
"mode": writeModeName(s),
|
||||
}, nil
|
||||
@@ -816,7 +785,7 @@ func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
s := &payload.Sheets[i]
|
||||
sheetID, ok := byName[s.Name]
|
||||
if !ok {
|
||||
rows, cols := sheetCreateDims(s, styles.styleFor(i))
|
||||
rows, cols := sheetCreateDims(s)
|
||||
sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols)
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||||
@@ -895,12 +864,10 @@ func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, nam
|
||||
|
||||
// sheetCreateDims sizes a to-be-created sheet to the spec's write range so the
|
||||
// follow-up set_cell_range can't exceed sheet bounds. It accounts for the
|
||||
// start_cell offset, the optional header row, and any --styles extent (so a
|
||||
// cell_styles / merge / resize op past the data still fits the grid). The
|
||||
// backend's 20×200 defaults are kept as floors (ordinary small tables are
|
||||
// created exactly as before) and its hard limits (200 cols, 50000 rows) as
|
||||
// ceilings.
|
||||
func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (rows, cols int) {
|
||||
// start_cell offset and the optional header row. The backend's 20×200 defaults
|
||||
// are kept as floors (ordinary small tables are created exactly as before) and
|
||||
// its hard limits (200 cols, 50000 rows) as ceilings.
|
||||
func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
cols = col0 + len(s.Columns)
|
||||
rows = row0 + len(s.Rows)
|
||||
@@ -915,19 +882,6 @@ func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (row
|
||||
if headerOn(s) || (s.Mode == "append" && s.Header == nil) {
|
||||
rows++
|
||||
}
|
||||
// --styles can reach past the data (cell_styles on blank cells get padded
|
||||
// into the matrix and written; merges / resizes run as separate ops). Size
|
||||
// the grid to cover them too. workbookCreateStyleDimensions returns the
|
||||
// extent relative to the anchor, so add the anchor offset back.
|
||||
if styles != nil {
|
||||
styleRows, styleCols := workbookCreateStyleDimensions(styles, col0, row0)
|
||||
if col0+styleCols > cols {
|
||||
cols = col0 + styleCols
|
||||
}
|
||||
if row0+styleRows > rows {
|
||||
rows = row0 + styleRows
|
||||
}
|
||||
}
|
||||
if cols < 20 {
|
||||
cols = 20
|
||||
}
|
||||
@@ -1025,6 +979,8 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
for i := range payload.Sheets {
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||||
s.Name, len(s.Rows), len(s.Columns), writeModeName(s))
|
||||
rng := tablePutFullRange(s, len(matrix))
|
||||
if s.Mode == "append" {
|
||||
rng = "<append below existing data>"
|
||||
@@ -1032,23 +988,10 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// cell_styles are merged into the matrix only for overwrite mode,
|
||||
// where the anchor row is known statically; append's base row is
|
||||
// resolved at execute time, so the preview leaves the matrix bare
|
||||
// (the merges / sizes ops below still render). Padding can widen /
|
||||
// lengthen the matrix past the data, so recompute the range from the
|
||||
// padded dims to match what Execute writes.
|
||||
// (the merges / sizes ops below still render).
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
if len(matrix) > 0 {
|
||||
rng = fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(col0), row0+1,
|
||||
columnIndexToLetter(col0+len(matrix[0])-1), row0+len(matrix))
|
||||
}
|
||||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
}
|
||||
writeCols := len(s.Columns)
|
||||
if len(matrix) > 0 {
|
||||
writeCols = len(matrix[0])
|
||||
}
|
||||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||||
s.Name, len(s.Rows), writeCols, writeModeName(s))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": s.Name,
|
||||
|
||||
@@ -537,15 +537,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec tableSheetSpec
|
||||
styles *workbookCreateStylePayload
|
||||
wantRows, wantCols int
|
||||
}{
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, nil, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, nil, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, nil, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, nil, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, nil, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, nil, 200, 200},
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
|
||||
// Default headerOn() is false for append mode, but writeSheetData forces
|
||||
// a header when append hits an empty sheet with no explicit Header
|
||||
// choice (so column names aren't lost). sheetCreateDims runs only on
|
||||
@@ -553,22 +552,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
// match: append + Header=nil ⇒ +1 row. Otherwise an append-near-50000
|
||||
// payload would be created one row short.
|
||||
{"append on new sheet sizes for the forced header row (49999 data rows + 1 header = 50000)",
|
||||
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, nil, 50000, 20},
|
||||
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, 50000, 20},
|
||||
{"append + Header=false (explicit) does NOT add the forced header row",
|
||||
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, nil, 200, 20},
|
||||
// --styles reaching past the data grows the grid so a cell_styles op on a
|
||||
// blank cell (or a merge / resize) still fits after the create.
|
||||
{"styles past the data grow the grid",
|
||||
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
|
||||
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z400"}}}, 400, 26},
|
||||
{"styles inside the data don't shrink the grid",
|
||||
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
|
||||
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:B2"}}}, 200, 20},
|
||||
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, 200, 20},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec, tt.styles)
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec)
|
||||
if gotRows != tt.wantRows || gotCols != tt.wantCols {
|
||||
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var Undo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+undo",
|
||||
Description: "Undo the current user's latest spreadsheet write.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
// History shortcuts keep locator flags hand-written because they share
|
||||
// revision/revert helpers; keep --count here in sync with sheet-skill-spec.
|
||||
Flags: append(historyLocatorFlags(),
|
||||
common.Flag{Name: "count", Type: "int", Default: "1", Desc: "Number of user undo stack entries to undo sequentially (1-20)."},
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Int("count") < 1 || runtime.Int("count") > 20 {
|
||||
return sheetsValidationForFlag("count", "--count must be between 1 and 20")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "undo_last", undoInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", undoInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func undoInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
// Always send count, including the default 1, so dry-run mirrors the exact
|
||||
// request body sent by Execute.
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"count": runtime.Int("count"),
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUndo_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := []string{"--url", testURL, "--as", "user"}
|
||||
callURL := dryRunFirstCallURL(t, Undo, args)
|
||||
if !containsSuffix(callURL, "invoke_write") {
|
||||
t.Errorf("invoke url = %q, want invoke_write", callURL)
|
||||
}
|
||||
|
||||
body := parseDryRunBody(t, Undo, args)
|
||||
got := decodeToolInput(t, body, "undo_last")
|
||||
assertInputEquals(t, got, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"count": float64(1),
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecute_Undo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := toolOutputStub(testToken, "write", `{"undone":3,"op_id":"op-3","top_doc_revision":30,"new_revision":31,"op_ids":["op-3","op-2","op-1"],"results":[{"op_id":"op-3","top_doc_revision":30,"new_revision":31},{"op_id":"op-2","top_doc_revision":20,"new_revision":32},{"op_id":"op-1","top_doc_revision":10,"new_revision":33}]}`)
|
||||
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", "3", "--as", "user"}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "undo_last")
|
||||
assertInputEquals(t, input, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"count": float64(3),
|
||||
})
|
||||
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["undone"].(float64) != 3 {
|
||||
t.Fatalf("unexpected output data: %#v", data)
|
||||
}
|
||||
results := data["results"].([]interface{})
|
||||
first := results[0].(map[string]interface{})
|
||||
if data["op_id"] != first["op_id"] ||
|
||||
data["top_doc_revision"] != first["top_doc_revision"] ||
|
||||
data["new_revision"] != first["new_revision"] {
|
||||
t.Fatalf("legacy fields are not aligned with first result: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_ValidateCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count string
|
||||
wantSub string
|
||||
}{
|
||||
{name: "zero", count: "0", wantSub: "--count must be between 1 and 20"},
|
||||
{name: "negative", count: "-1", wantSub: "--count must be between 1 and 20"},
|
||||
{name: "too large", count: "21", wantSub: "--count must be between 1 and 20"},
|
||||
{name: "non integer", count: "1.5", wantSub: "invalid argument"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", tt.count, "--as", "user"})
|
||||
if err == nil {
|
||||
t.Fatal("expected count validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantSub) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_UndoReasonOutputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
outputJSON string
|
||||
wantReason string
|
||||
wantUndone float64
|
||||
}{
|
||||
{
|
||||
name: "partial success because requested count exceeds stack",
|
||||
outputJSON: `{"undone":2,"reason":"undo_stack_empty","op_ids":["op-2","op-1"],"results":[{"op_id":"op-2"},{"op_id":"op-1"}],"warning_message":"Requested 3 undo step(s), but only 2 active undo entries were available."}`,
|
||||
wantReason: "undo_stack_empty",
|
||||
wantUndone: 2,
|
||||
},
|
||||
{
|
||||
name: "raw undo changeset missing or expired",
|
||||
outputJSON: `{"undone":0,"reason":"undo_entry_missing","warning_message":"The undo stack entry points to raw undo changeset data that is missing or expired. Use history_list/history_revert to roll the spreadsheet back via history."}`,
|
||||
wantReason: "undo_entry_missing",
|
||||
wantUndone: 0,
|
||||
},
|
||||
{
|
||||
name: "unsupported object undo",
|
||||
outputJSON: `{"undone":0,"reason":"unsupported_object_undo","warning_message":"The top undo entry contains changes this undo implementation cannot replay."}`,
|
||||
wantReason: "unsupported_object_undo",
|
||||
wantUndone: 0,
|
||||
},
|
||||
{
|
||||
name: "apply failed",
|
||||
outputJSON: `{"undone":0,"reason":"undo_apply_failed","warning_message":"Failed to apply the top undo entry."}`,
|
||||
wantReason: "undo_apply_failed",
|
||||
wantUndone: 0,
|
||||
},
|
||||
{
|
||||
name: "marker persist failed after apply",
|
||||
outputJSON: `{"undone":1,"reason":"undo_state_persist_failed","op_ids":["op-1"],"results":[{"op_id":"op-1","reason":"undo_state_persist_failed","warning_message":"Undo was applied, but the marker was not persisted."}]}`,
|
||||
wantReason: "undo_state_persist_failed",
|
||||
wantUndone: 1,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", tt.outputJSON)
|
||||
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--as", "user"}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["undone"].(float64) != tt.wantUndone || data["reason"] != tt.wantReason {
|
||||
t.Fatalf("unexpected output data: %#v", data)
|
||||
}
|
||||
if tt.wantReason == "undo_entry_missing" && !strings.Contains(data["warning_message"].(string), "history_list/history_revert") {
|
||||
t.Fatalf("missing history fallback hint: %#v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -123,26 +123,6 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return nil, common.ValidationErrorf("--title is required")
|
||||
}
|
||||
// --type bitable 建一张空白多维表格子表(operation=create_bitable);默认 sheet 为普通
|
||||
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令,row-count/col-count 不适用。
|
||||
sheetType := strings.TrimSpace(runtime.Str("type"))
|
||||
if sheetType == "" {
|
||||
sheetType = "sheet"
|
||||
}
|
||||
if sheetType != "sheet" && sheetType != "bitable" {
|
||||
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
|
||||
}
|
||||
if sheetType == "bitable" {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "create_bitable",
|
||||
"sheet_name": strings.TrimSpace(runtime.Str("title")),
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
input["target_index"] = runtime.Int("index")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
|
||||
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
|
||||
}
|
||||
@@ -654,26 +634,16 @@ var WorkbookCreate = common.Shortcut{
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
// Padding can widen / lengthen the matrix past the data, so build the
|
||||
// range from the padded dims to match what Execute writes.
|
||||
rng := tablePutFullRange(s, len(matrix))
|
||||
writeCols := len(s.Columns)
|
||||
if len(matrix) > 0 {
|
||||
writeCols = len(matrix[0])
|
||||
rng = fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(col0), row0+1,
|
||||
columnIndexToLetter(col0+writeCols-1), row0+len(matrix))
|
||||
}
|
||||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": "<new-token>",
|
||||
"sheet_name": s.Name,
|
||||
"range": rng,
|
||||
"range": tablePutFullRange(s, len(matrix)),
|
||||
"cells": matrix,
|
||||
}
|
||||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), writeCols)).
|
||||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
|
||||
Body(wireBody)
|
||||
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", s.Name, sheetStyles.styleFor(i))
|
||||
}
|
||||
@@ -866,19 +836,13 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
|
||||
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
|
||||
}
|
||||
noHeader := false
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
return &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Name: valuesSheetName,
|
||||
Mode: "overwrite",
|
||||
Header: &noHeader,
|
||||
Columns: cols,
|
||||
Rows: rows,
|
||||
}}}
|
||||
// --values bypasses tablePayload.validate(), so enforce the cell budget here
|
||||
// too — otherwise a giant --values array materializes unbounded.
|
||||
if err := payload.checkCellBudget(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
|
||||
@@ -1282,7 +1246,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
case "font_color", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap":
|
||||
return true
|
||||
@@ -1393,79 +1357,20 @@ func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol,
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
// padMatrixForStyles grows the matrix down and right so it covers every
|
||||
// cell_styles range, appending empty cells for the positions the data doesn't
|
||||
// reach. cell_styles are applied by writing into the matrix in place (see
|
||||
// applyWorkbookCreateStylesToMatrix), so a style on a cell past the data extent
|
||||
// needs a real — if empty — cell to attach to. This lets a create/write set
|
||||
// styles on blank cells (reserved regions, decorative headers, empty borders).
|
||||
//
|
||||
// Only cell_styles contribute to the extent: cell_merges / row_sizes / col_sizes
|
||||
// run as separate API calls (see workbookCreateVisualOps) and never touch the
|
||||
// matrix. Ranges that start left of baseCol or above baseRow are skipped here
|
||||
// (the matrix can only extend down/right) and left for the caller to reject.
|
||||
// The (possibly reallocated) matrix is returned; callers must use the result.
|
||||
func padMatrixForStyles(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int) [][]interface{} {
|
||||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error {
|
||||
if styles == nil {
|
||||
return rows
|
||||
return nil
|
||||
}
|
||||
needRows := len(rows)
|
||||
needCols := 0
|
||||
if len(rows) > 0 {
|
||||
needCols = len(rows[0])
|
||||
}
|
||||
for _, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil || startCol < baseCol || startRow < baseRow {
|
||||
continue // unparsable, or up/left of the anchor: not paddable
|
||||
}
|
||||
if endCol-baseCol+1 > needCols {
|
||||
needCols = endCol - baseCol + 1
|
||||
}
|
||||
if endRow-baseRow+1 > needRows {
|
||||
needRows = endRow - baseRow + 1
|
||||
}
|
||||
}
|
||||
// Widen existing rows to needCols.
|
||||
for r := range rows {
|
||||
for len(rows[r]) < needCols {
|
||||
rows[r] = append(rows[r], map[string]interface{}{})
|
||||
}
|
||||
}
|
||||
// Append full empty rows to reach needRows.
|
||||
for len(rows) < needRows {
|
||||
row := make([]interface{}, needCols)
|
||||
for c := range row {
|
||||
row[c] = map[string]interface{}{}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// applyWorkbookCreateStylesToMatrix pads the matrix to cover the cell_styles
|
||||
// ranges (see padMatrixForStyles), merges each op's style into the covered
|
||||
// cells, and returns the padded matrix. A range that starts left of / above the
|
||||
// write anchor can't be padded to and is rejected.
|
||||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) ([][]interface{}, error) {
|
||||
if styles == nil {
|
||||
return rows, nil
|
||||
}
|
||||
rows = padMatrixForStyles(rows, styles, baseCol, baseRow)
|
||||
for i, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil {
|
||||
return rows, common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||||
return common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||||
}
|
||||
// After padding, the matrix reaches every range that starts at or after
|
||||
// the anchor; a start left of / above it can't be covered. The endRow /
|
||||
// endCol checks stay as a defensive backstop (padding should have made
|
||||
// them unreachable).
|
||||
if startCol < baseCol || startRow < baseRow || len(rows) == 0 ||
|
||||
endRow-baseRow >= len(rows) || endCol-baseCol >= len(rows[0]) {
|
||||
return rows, common.ValidationErrorf("%s[%d].range %q starts outside the write range (its top-left must be at or after %s%d)",
|
||||
if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) {
|
||||
return common.ValidationErrorf("%s[%d].range %q is outside the write range %s%d:%s%d",
|
||||
label, i, op.Range,
|
||||
columnIndexToLetter(baseCol), baseRow+1)
|
||||
columnIndexToLetter(baseCol), baseRow+1,
|
||||
columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows))
|
||||
}
|
||||
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
|
||||
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
|
||||
@@ -1473,7 +1378,7 @@ func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCre
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) {
|
||||
|
||||
@@ -396,37 +396,6 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style past the data pads empty cells so blank cells can be styled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Data is a single cell (A1), but the style targets A1:C3 — the matrix is
|
||||
// padded to 3x3 with empty cells so the style range fits, letting blank
|
||||
// cells carry styling. The written range must reflect the padded extent.
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:C3","background_color":"#FFEEAA"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:C3" {
|
||||
t.Errorf("range = %v, want A1:C3 (padded to the style extent)", input["range"])
|
||||
}
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Fatalf("cells rows = %d, want 3 (padded); cells=%#v", len(cells), input["cells"])
|
||||
}
|
||||
lastRow, _ := cells[2].([]interface{})
|
||||
if len(lastRow) != 3 {
|
||||
t.Fatalf("last row width = %d, want 3 (padded)", len(lastRow))
|
||||
}
|
||||
// A blank padded cell (C3) still carries the style.
|
||||
c3, _ := lastRow[2].(map[string]interface{})
|
||||
c3s, _ := c3["cell_styles"].(map[string]interface{})
|
||||
if c3s["background_color"] != "#FFEEAA" {
|
||||
t.Errorf("padded blank cell C3 style = %#v, want background_color", c3)
|
||||
}
|
||||
})
|
||||
t.Run("style-only payload (cell_merges) still fills and emits merge_cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Previously workbookCreateStyleDimensions only counted cell_styles, so a
|
||||
@@ -626,60 +595,3 @@ func deepEqualJSON(a, b interface{}) bool {
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
// TestApplyWorkbookCreateStylesToMatrix covers the pad-then-style behavior
|
||||
// directly: a style range past the data grows the matrix with empty cells (so
|
||||
// blank cells can be styled), an in-range style leaves the matrix size alone,
|
||||
// and a range up/left of the anchor — which padding can't reach — is rejected.
|
||||
func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell := func() interface{} { return map[string]interface{}{} }
|
||||
|
||||
t.Run("pads down and right for a style past the data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell()}} // 1x1 data
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1:C3", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"background_color": "#FFEEAA"}}},
|
||||
}}
|
||||
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) != 3 || len(out[0]) != 3 || len(out[2]) != 3 {
|
||||
t.Fatalf("padded matrix = %d rows x %v cols, want 3x3", len(out), out)
|
||||
}
|
||||
// A blank padded corner (C3) carries the style.
|
||||
c3, _ := out[2][2].(map[string]interface{})
|
||||
c3s, _ := c3["cell_styles"].(map[string]interface{})
|
||||
if c3s["background_color"] != "#FFEEAA" {
|
||||
t.Errorf("padded cell C3 = %#v, want background_color", c3)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no pad when the style is within the data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell(), cell()}, {cell(), cell()}} // 2x2 data
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1:B2", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
|
||||
}}
|
||||
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) != 2 || len(out[0]) != 2 {
|
||||
t.Errorf("matrix = %dx%d, want 2x2 (no pad)", len(out), len(out[0]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a range up/left of the anchor", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell()}} // anchored at C3 (col 2, row 2)
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
|
||||
}}
|
||||
_, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 2, 2, "--styles")
|
||||
if err == nil || !strings.Contains(err.Error(), "starts outside the write range") {
|
||||
t.Errorf("err = %v, want 'starts outside the write range'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
|
||||
// CellsSetStyle stamps a single style block across every cell in --range.
|
||||
// Style is composed from a dozen flat flags (background-color, font-color,
|
||||
// font-family, font-size, font-style, font-weight, font-line,
|
||||
// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus
|
||||
// --border-styles for the only field that still needs a nested object. At
|
||||
// least one flag must be set.
|
||||
// font-size, font-style, font-weight, font-line, horizontal-alignment,
|
||||
// vertical-alignment, word-wrap, number-format) plus --border-styles for
|
||||
// the only field that still needs a nested object. At least one flag must
|
||||
// be set.
|
||||
var CellsSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set-style",
|
||||
@@ -165,9 +165,6 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -453,9 +450,6 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation, err := buildDropdownValidation(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -631,23 +625,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||||
}
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("empty range")
|
||||
}
|
||||
parts := strings.SplitN(rangeStr, ":", 2)
|
||||
if len(parts) == 1 {
|
||||
// single cell, e.g. "A1"
|
||||
if _, _, ok := splitCellRef(parts[0]); !ok {
|
||||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
startCol, startRow, ok1 := splitCellRef(parts[0])
|
||||
endCol, endRow, ok2 := splitCellRef(parts[1])
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
|
||||
}
|
||||
if endRow < startRow || endCol < startCol {
|
||||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
|
||||
}
|
||||
return endRow - startRow + 1, endCol - startCol + 1, nil
|
||||
}
|
||||
@@ -698,30 +692,9 @@ func letterToColumnIndex(letters string) int {
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
|
||||
// will materialize from a single A1 range. The backing tools take an explicit
|
||||
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
|
||||
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
|
||||
// doubled again by json.Marshal) that OOMs the process before the request even
|
||||
// leaves. 200000 matches the documented --max-cells safety cap.
|
||||
const maxStampMatrixCells = 200000
|
||||
|
||||
// checkStampMatrixBudget rejects a range whose materialized cell count would
|
||||
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
|
||||
// computed in int64 to stay safe against overflow on pathological ranges.
|
||||
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
|
||||
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
|
||||
return sheetsValidationForFlag(flagName,
|
||||
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
|
||||
rangeStr, total, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
|
||||
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
|
||||
// single attribute (style / data_validation) across an entire range.
|
||||
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
|
||||
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
|
||||
cells := make([][]interface{}, rows)
|
||||
for r := range cells {
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
|
||||
// synthetic token and must upload with "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -36,12 +36,9 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
}{
|
||||
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
|
||||
{"empty token", "", sheetImageParentType},
|
||||
{"fake_office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"fake_office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
|
||||
{"local_office token, only the prefix", localOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
|
||||
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -65,8 +62,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
wantParentType string
|
||||
}{
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
|
||||
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These benchmarks back the memory review of the sheets fan-out / download
|
||||
// paths. They measure two hot spots:
|
||||
//
|
||||
// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set,
|
||||
// +cells-batch-set-style, +dropdown-update) expand one A1 range into a
|
||||
// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000")
|
||||
// explodes into millions of heap maps with no upper bound.
|
||||
//
|
||||
// 2. the export-download reader — strings.NewReader(string(rawBody)) copies
|
||||
// the whole downloaded file once more before saving it.
|
||||
//
|
||||
// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|DownloadReader' -benchmem
|
||||
|
||||
var styleProto = map[string]interface{}{
|
||||
"cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"},
|
||||
"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}},
|
||||
}
|
||||
|
||||
func benchFillCellsMatrix(b *testing.B, rows, cols int) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m := fillCellsMatrix(rows, cols, styleProto)
|
||||
if len(m) != rows {
|
||||
b.Fatalf("bad matrix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10
|
||||
func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000
|
||||
func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000
|
||||
func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000
|
||||
|
||||
// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of
|
||||
// materializing a large fan-out matrix, so the review doc can quote real MB.
|
||||
// Not an assertion — it prints numbers under `go test -v -run PeakMemory`.
|
||||
func TestFanoutMatrixPeakMemory(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping memory probe in -short")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rows, cols int
|
||||
}{
|
||||
{"A1:Z10000 (260K cells)", 10000, 26},
|
||||
{"A1:Z100000 (2.6M cells)", 100000, 26},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
m := fillCellsMatrix(c.rows, c.cols, styleProto)
|
||||
runtime.ReadMemStats(&after)
|
||||
runtime.KeepAlive(m)
|
||||
t.Logf("%-26s heap +%6.1f MB (%d total allocs)",
|
||||
c.name,
|
||||
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
|
||||
after.Mallocs-before.Mallocs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- +table-put / +workbook-create matrix materialization (sibling #1 path) ---
|
||||
//
|
||||
// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix
|
||||
// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the
|
||||
// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this
|
||||
// in-memory build). checkCellBudget rejects oversized payloads before this runs.
|
||||
|
||||
func makeTypelessSpec(rows, cols int) *tableSheetSpec {
|
||||
c := make([]tableColumnSpec, cols)
|
||||
r := make([][]interface{}, rows)
|
||||
for i := range r {
|
||||
row := make([]interface{}, cols)
|
||||
for j := range row {
|
||||
row[j] = "x"
|
||||
}
|
||||
r[i] = row
|
||||
}
|
||||
return &tableSheetSpec{Columns: c, Rows: r}
|
||||
}
|
||||
|
||||
func benchBuildSheetMatrix(b *testing.B, rows, cols int) {
|
||||
spec := makeTypelessSpec(rows, cols)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m, err := buildSheetMatrix(spec, true)
|
||||
if err != nil || len(m) != rows+1 {
|
||||
b.Fatalf("bad matrix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells
|
||||
func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells
|
||||
|
||||
// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing
|
||||
// a large table-put matrix (the cost checkCellBudget now prevents), so the
|
||||
// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory.
|
||||
func TestTablePutMatrixPeakMemory(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping memory probe in -short")
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
rows, cols int
|
||||
}{
|
||||
{"100000×26 (2.6M cells)", 100000, 26},
|
||||
} {
|
||||
spec := makeTypelessSpec(c.rows, c.cols)
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
m, _ := buildSheetMatrix(spec, true)
|
||||
runtime.ReadMemStats(&after)
|
||||
runtime.KeepAlive(m)
|
||||
t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)",
|
||||
c.name,
|
||||
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
|
||||
after.Mallocs-before.Mallocs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- export-download reader copy ---
|
||||
|
||||
func benchDownloadReader(b *testing.B, size int, useStringCopy bool) {
|
||||
raw := bytes.Repeat([]byte("x"), size)
|
||||
sink := make([]byte, 32*1024)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var r io.Reader
|
||||
if useStringCopy {
|
||||
r = strings.NewReader(string(raw)) // current code: extra full-size copy
|
||||
} else {
|
||||
r = bytes.NewReader(raw) // fix: no copy
|
||||
}
|
||||
for {
|
||||
if _, err := r.Read(sink); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) ---
|
||||
|
||||
func TestStampMatrixBudgetCap(t *testing.T) {
|
||||
// 199992 cells (7692×26) sits just under the 200000 cap → allowed.
|
||||
if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil {
|
||||
t.Fatalf("199992 cells should pass, got: %v", err)
|
||||
}
|
||||
// Exactly at the cap → allowed.
|
||||
if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil {
|
||||
t.Fatalf("200000 cells (== cap) should pass, got: %v", err)
|
||||
}
|
||||
// Just over the cap → rejected.
|
||||
if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil {
|
||||
t.Fatal("200001 cells should be rejected")
|
||||
}
|
||||
// The pathological case from the review (2.6M cells) → rejected.
|
||||
if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil {
|
||||
t.Fatal("2.6M-cell fan-out should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate,
|
||||
// batch-update operation count (follow-up to the single fan-out cap) ---
|
||||
|
||||
// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap:
|
||||
// buildSheetMatrix builds the whole matrix in memory, so the total cell count is
|
||||
// bounded before that allocation, summed across all sheets.
|
||||
func TestTablePutCellBudgetCap(t *testing.T) {
|
||||
// 1000×1000 = 1,000,000 == cap → allowed.
|
||||
atCap := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Columns: make([]tableColumnSpec, 1000),
|
||||
Rows: make([][]interface{}, 1000),
|
||||
}}}
|
||||
if err := atCap.checkCellBudget(); err != nil {
|
||||
t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err)
|
||||
}
|
||||
// 1000×1001 = 1,001,000 > cap → rejected.
|
||||
over := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Columns: make([]tableColumnSpec, 1000),
|
||||
Rows: make([][]interface{}, 1001),
|
||||
}}}
|
||||
if err := over.checkCellBudget(); err == nil {
|
||||
t.Fatal("1,001,000 cells should be rejected")
|
||||
}
|
||||
// Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap.
|
||||
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
|
||||
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
|
||||
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
|
||||
}}
|
||||
if err := twoSheets.checkCellBudget(); err == nil {
|
||||
t.Fatal("1.2M cells across two sheets should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the
|
||||
// per-range cap can't stop many ranges from summing past the matrix ceiling.
|
||||
func TestBatchStampAggregateCap(t *testing.T) {
|
||||
if err := checkBatchStampBudget(maxStampMatrixCells); err != nil {
|
||||
t.Fatalf("aggregate == cap should pass, got: %v", err)
|
||||
}
|
||||
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
|
||||
t.Fatal("aggregate over cap should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges
|
||||
// ranges and expects the shared validateDropdownRanges cap to reject it.
|
||||
func TestBatchFanoutRangeCountCap(t *testing.T) {
|
||||
ranges := make([]string, maxBatchRanges+1)
|
||||
for i := range ranges {
|
||||
ranges[i] = "sheet1!A1"
|
||||
}
|
||||
rangesJSON, _ := json.Marshal(ranges)
|
||||
_, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", string(rangesJSON),
|
||||
"--font-weight", "bold",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "at most")
|
||||
}
|
||||
|
||||
// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap.
|
||||
func TestBatchOperationsCountCap(t *testing.T) {
|
||||
ops := make([]interface{}, maxBatchOperations+1)
|
||||
for i := range ops {
|
||||
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
|
||||
}
|
||||
_, err := translateBatchOperations(ops, testURL)
|
||||
if err == nil || !strings.Contains(err.Error(), "at most") {
|
||||
t.Fatalf("expected operations count cap error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same
|
||||
// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB /
|
||||
// 5.3M allocs is now rejected up front, allocating only the error string.
|
||||
func BenchmarkStampBudget_RejectsOversized(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil {
|
||||
b.Fatal("expected rejection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDownloadReader_StringCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, true) }
|
||||
func BenchmarkDownloadReader_BytesNoCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, false) }
|
||||
func BenchmarkDownloadReader_StringCopy_16MB(b *testing.B) { benchDownloadReader(b, 16<<20, true) }
|
||||
func BenchmarkDownloadReader_BytesNoCopy_16MB(b *testing.B) {
|
||||
benchDownloadReader(b, 16<<20, false)
|
||||
}
|
||||
@@ -70,7 +70,6 @@ func shortcutList() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
// lark_sheet_workbook
|
||||
WorkbookInfo,
|
||||
RevisionGet,
|
||||
SheetCreate,
|
||||
SheetDelete,
|
||||
SheetRename,
|
||||
@@ -96,9 +95,6 @@ func shortcutList() []common.Shortcut {
|
||||
DimUngroup,
|
||||
DimMove,
|
||||
|
||||
// lark_sheet_changeset
|
||||
ChangesetGet,
|
||||
|
||||
// lark_sheet_read_data
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
@@ -109,9 +105,6 @@ func shortcutList() []common.Shortcut {
|
||||
CellsSearch,
|
||||
CellsReplace,
|
||||
|
||||
// lark_sheet_formula_verify
|
||||
FormulaVerify,
|
||||
|
||||
// lark_sheet_write_cells
|
||||
CellsSet,
|
||||
CellsSetStyle,
|
||||
@@ -155,11 +148,5 @@ func shortcutList() []common.Shortcut {
|
||||
CellsBatchClear,
|
||||
DropdownUpdate,
|
||||
DropdownDelete,
|
||||
|
||||
// lark_sheet_history
|
||||
HistoryList,
|
||||
HistoryRevert,
|
||||
HistoryRevertStatus,
|
||||
Undo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
@@ -194,6 +195,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||
|
||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
**CRITICAL — 凡涉及预约日程/会议室、调整时间或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -30,28 +30,84 @@ lark-cli calendar +agenda --as user
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
|
||||
| `+agenda` | 查看日程安排(默认今天) |
|
||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
| `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion`) |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||
|
||||
### `+get` — 单日程详情
|
||||
|
||||
通过 `calendar_id` + `event_id` 获取**单个日程**详情。
|
||||
|
||||
```bash
|
||||
# calendar_id不传,默认primary
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||
|
||||
```bash
|
||||
# query 按关键词 可选
|
||||
# start/end 按时间范围(ISO 8601 或 YYYY-MM-DD)可选
|
||||
# attendee-ids 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)可选
|
||||
# page-token 分页游标,用于继续翻页 可选
|
||||
# page-size 每页数量,默认 30 可选
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1,oc_chat1,omm_room1" --page-token <page_token> --page-size 30
|
||||
```
|
||||
|
||||
### `+agenda` — 查看近期日程安排
|
||||
|
||||
默认查询当天。结果应整理为按日期分组、按开始时间升序的易读时间线。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# calendar-id 日历 ID(默认primary)可选
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17 --calendar-id <calendar_id>
|
||||
```
|
||||
|
||||
注意:
|
||||
- 已取消的日程自动过滤;无日程时直接告知"日程清空"。
|
||||
- 时间范围超过 40 天会自动拆分查询并合并结果。
|
||||
|
||||
### `+freebusy` — 查询主日历忙闲时段和 RSVP 状态
|
||||
|
||||
仅返回忙碌时段起止时间,不含日程标题等隐私信息;其他订阅日历不在范围内。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# user-id 目标用户 open_id(ou_ 前缀)可选;默认当前登录用户,bot 身份必须显式指定
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
```
|
||||
|
||||
用法提示:
|
||||
- **仅判断是否有空** → `+freebusy`;**需要日程详情** → `+agenda`。
|
||||
- 检查多人可用性:分别调用并对比,找共同空闲。
|
||||
- 预约/改约场景下,调用规则(参与人过多、含群组、来自 `+suggestion` 等)详见 [schedule-clear-time.md § 查询忙闲](references/lark-calendar-schedule-clear-time.md#2-查询忙闲)。
|
||||
|
||||
## 前置条件路由
|
||||
|
||||
| 场景 | 前置要求 |
|
||||
|------|----------|
|
||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||
| 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
|
||||
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||
|
||||
## 写操作反馈
|
||||
|
||||
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`。
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||
- **重复性日程例外(Exception)**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
|
||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||
@@ -70,7 +126,9 @@ lark-cli calendar +agenda --as user
|
||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
|
||||
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||
|
||||
## 任务类型分流
|
||||
|
||||
@@ -87,7 +145,7 @@ lark-cli calendar +agenda --as user
|
||||
|
||||
## 会议室规则
|
||||
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)。
|
||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||
@@ -95,42 +153,45 @@ lark-cli calendar +agenda --as user
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
# 通用调用格式
|
||||
lark-cli calendar <resource> <method> [flags]
|
||||
|
||||
# 查询用户主日历
|
||||
lark-cli calendar calendars primary
|
||||
|
||||
# 获取日程分享链接
|
||||
lark-cli calendar events share_info --calendar-id <calendar_id> --event-id <event_id>
|
||||
|
||||
# 删除日程
|
||||
lark-cli calendar events delete --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
### calendars
|
||||
> `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
|
||||
|
||||
- `create` — 创建共享日历
|
||||
- `delete` — 删除共享日历
|
||||
- `get` — 查询日历信息
|
||||
- `list` — 查询日历列表
|
||||
- `patch` — 更新日历信息
|
||||
- `primary` — 查询用户主日历
|
||||
- `search` — 搜索日历
|
||||
### 查询资源的方法列表以及方法的使用方式
|
||||
|
||||
### event.attendees
|
||||
- 列出某资源下的方法:`lark-cli calendar <resource> -h`
|
||||
- 查看方法的cli flag:`lark-cli calendar <resource> <method> -h`
|
||||
- 查看方法API参数:`lark-cli schema calendar.<resource>.<method>`
|
||||
|
||||
- `batch_delete` — 删除日程参与人
|
||||
- `create` — 添加日程参与人
|
||||
- `list` — 获取日程参与人列表
|
||||
`<resource>` 为 `calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`。
|
||||
|
||||
### events
|
||||
## 常用其他域命令
|
||||
|
||||
- `create` — 创建日程
|
||||
- `delete` — 删除日程
|
||||
- `get` — 获取日程
|
||||
- `instance_view` — 查询日程视图
|
||||
- `patch` — 更新日程
|
||||
- `share_info` — 获取日程分享链接
|
||||
```bash
|
||||
# 搜索用户,更多参数详见 lark-contact
|
||||
lark-cli contact +search-user --query <query> --as user
|
||||
|
||||
### freebusys
|
||||
|
||||
- `list` — 查询主日历日程忙闲信息
|
||||
# 搜索群聊,更多参数详见 lark-im
|
||||
lark-cli im +chat-search --query <query> --as user
|
||||
```
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||
- 通讯录 → [lark-contact](../lark-contact/SKILL.md)
|
||||
- 即时通讯 → [lark-im](../lark-im/SKILL.md)
|
||||
- 会议室物理设施管理 → 管理员后台
|
||||
|
||||
**注意(强制性):**
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
|
||||
# calendar +agenda
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查看近期日程安排。只读操作,不修改任何日程。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查看今天日程(默认)
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# 自定义时间范围(ISO 8601)
|
||||
lark-cli calendar +agenda --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +agenda --format pretty
|
||||
|
||||
# 指定日历
|
||||
lark-cli calendar +agenda --calendar-id cal_xxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-10T14:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-10 14:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-10` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
**将结果整理为易读的日程表:**
|
||||
|
||||
```
|
||||
## 2026-03-10 周一
|
||||
|
||||
09:00 - 09:30 站会
|
||||
10:00 - 11:00 产品评审
|
||||
14:00 - 15:00 与 Alice 1:1
|
||||
|
||||
## 2026-03-11 周二
|
||||
|
||||
(无日程)
|
||||
```
|
||||
|
||||
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长
|
||||
|
||||
## 提示
|
||||
|
||||
- 已取消的日程会自动过滤,无需额外处理。
|
||||
- 如无日程,告知用户"日程清空"。
|
||||
- 大于 40 天的时间范围会自动拆分查询并合并结果。
|
||||
- 查看多个日历:先用 `lark-cli calendar calendars list --page-all` 列出日历列表,再逐个查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,12 +1,9 @@
|
||||
|
||||
# calendar +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建日程并按需邀请参会人。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -38,15 +35,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -55,37 +53,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
||||
|
||||
```bash
|
||||
# 第一步:创建日程(含高级参数)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.create
|
||||
## 创建日程
|
||||
lark-cli calendar events create \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>"}' \
|
||||
--data '{
|
||||
"summary": "技术分享:CLI 架构设计",
|
||||
"start_time": { "timestamp": "1741586400" },
|
||||
"end_time": { "timestamp": "1741593600" }
|
||||
}'
|
||||
|
||||
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.event.attendees.create
|
||||
## 添加参会人
|
||||
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
||||
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||
|
||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.delete
|
||||
## 删除空日程
|
||||
lark-cli calendar events delete \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
|
||||
|
||||
```
|
||||
|
||||
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
|
||||
完整 API 命令的关键差异:
|
||||
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
|
||||
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
|
||||
|
||||
## 参会人类型
|
||||
|
||||
@@ -101,6 +78,5 @@ lark-cli calendar events delete \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
# calendar +freebusy
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
查询用户主日历的忙闲信息,返回指定时间范围内的忙碌时段列表和rsvp的状态。
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询当前用户今天的忙闲(默认)
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12
|
||||
|
||||
# 自定义时间范围(完整 ISO 8601)
|
||||
lark-cli calendar +freebusy --start "2026-03-11T08:00:00+08:00" --end "2026-03-11T18:00:00+08:00"
|
||||
|
||||
# 查询指定用户的忙闲信息
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +freebusy --format pretty
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 查询开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 查询结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--user-id <open_id>` | 否 | 目标查询用户 ID(`ou_` 前缀)。省略时默认查询当前登录用户,bot 身份调用时必须明确指定 |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-11T09:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-11 09:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-11` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出示例
|
||||
|
||||
### 表格格式
|
||||
|
||||
```
|
||||
start end rsvp_status
|
||||
---------------- ---------------- -----------
|
||||
2026-03-11 10:00 2026-03-11 10:30 接受
|
||||
2026-03-11 14:00 2026-03-11 15:00 待定
|
||||
|
||||
共 2 个忙碌时段
|
||||
```
|
||||
|
||||
### JSON 格式
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"start_time": "2026-03-11T10:00:00+08:00",
|
||||
"end_time": "2026-03-11T10:30:00+08:00",
|
||||
"rsvp_status": "accept"
|
||||
},
|
||||
{
|
||||
"start_time": "2026-03-11T14:00:00+08:00",
|
||||
"end_time": "2026-03-11T15:00:00+08:00",
|
||||
"rsvp_status": "tentative"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 1. 查找日程会议空闲时段
|
||||
|
||||
```bash
|
||||
# 查询今天的忙碌时段
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 查询工作时间段
|
||||
lark-cli calendar +freebusy \
|
||||
--start "2026-03-11T08:00:00+08:00" \
|
||||
--end "2026-03-11T18:00:00+08:00"
|
||||
```
|
||||
|
||||
### 2. 检查团队成员可用性
|
||||
|
||||
```bash
|
||||
# 查询多个成员,对比找出共同空闲时间
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_a
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **只查询主日历** — 此命令只返回用户主日历的忙闲信息,不包括其他订阅日历
|
||||
2. **隐私保护** — 只返回忙碌时段的起止时间,不包含日程标题、描述等详细信息
|
||||
3. **bot 身份** — bot 必须通过 `--user-id` 指定要查询的用户
|
||||
|
||||
## 与其他命令对比
|
||||
|
||||
| 命令 | 用途 | 输出内容 |
|
||||
|------|------|----------|
|
||||
| `calendar +freebusy` | 查询忙闲时段 | 只返回忙碌时段列表(无日程详情) |
|
||||
| `calendar +agenda` | 查看日程安排 | 返回完整日程列表(含标题、描述等) |
|
||||
|
||||
**选择建议**:
|
||||
- **仅需了解是否有空** → 使用 `+freebusy`(更快,隐私保护)
|
||||
- **需要查看日程详情** → 使用 `+agenda`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 重复性日程操作规范
|
||||
|
||||
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
|
||||
|
||||
## 关键概念
|
||||
|
||||
- **event_id 结构**:`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime` 为 `0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`。
|
||||
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性(标题、时间、rrule、描述等)都挂在本体上。
|
||||
- **例外(Exception)**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}`(`originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
|
||||
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
|
||||
|
||||
## 前置步骤(所有范围通用)
|
||||
|
||||
1. 通过 `+agenda` 或 `+search-event` 定位重复性日程,获取原重复性日程的 `event_id`。
|
||||
2. 通过 `events instance_view` 或 `+agenda` 列出实例,识别哪些是例外(`event_id` 中 `originalTime > 0` 的即为例外)。
|
||||
3. 确认用户的操作范围。
|
||||
|
||||
## 编辑全部(更新时间)
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
|
||||
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
|
||||
|
||||
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
|
||||
|
||||
## 编辑全部(更新非时间字段)
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
|
||||
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||
|
||||
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
|
||||
|
||||
## 删除全部
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
|
||||
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
|
||||
|
||||
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
|
||||
|
||||
## 编辑此次及后续
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程(UNTIL 设为指定时间前一次实例的日期) |
|
||||
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
|
||||
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
|
||||
|
||||
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」,UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
|
||||
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
|
||||
|
||||
## 删除此次及后续
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程(UNTIL 设为指定时间前一次实例的日期) |
|
||||
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
|
||||
|
||||
> 与「编辑此次及后续」的区别:不需要步骤 3(创建新的重复性日程),因为目标是删除后续而非替换。
|
||||
|
||||
## 仅此次
|
||||
|
||||
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`。
|
||||
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`。
|
||||
|
||||
## 用户意图映射
|
||||
|
||||
| 用户表达 | 操作范围 |
|
||||
|----------|----------|
|
||||
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
|
||||
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
|
||||
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
|
||||
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
|
||||
| 「就改这一次」「只删这一次」 | 仅此次 |
|
||||
| 未明确范围 | **必须询问用户** |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) — 日历全部命令
|
||||
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -1,11 +1,8 @@
|
||||
# calendar +room-find
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 已知一个或多个待选时间块,需要查找可用会议室
|
||||
@@ -50,7 +47,7 @@ lark-cli calendar +room-find \
|
||||
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
||||
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
||||
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名或会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
@@ -67,7 +64,7 @@ lark-cli calendar +room-find \
|
||||
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。
|
||||
- **多会议室名称场景**:当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- 当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
||||
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
||||
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
||||
@@ -90,9 +87,8 @@ lark-cli calendar +room-find \
|
||||
```
|
||||
|
||||
> **AI 行为指导:**
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示,并直接询问用户意向。
|
||||
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。
|
||||
> - **主动识别区间/多名称意图**:当用户提到"帮我约 XX 到 YY 号的会议室""XX~YY 之间的会议室"或一次列出多个会议室名称时,将所有目标名称展开为英文逗号分隔列表,传入 `--room-name`。例如"帮我约 16 到 20 号的会议室"应生成 `--room-name "16,17,18,19,20"`。
|
||||
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
|
||||
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
||||
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
||||
@@ -102,7 +98,7 @@ lark-cli calendar +room-find \
|
||||
| 字段名 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
||||
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 |
|
||||
| `room_name` | 会议室名称,展示给用户时必须使用原值。 |
|
||||
| `capacity` | 会议室最大容纳人数。 |
|
||||
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
||||
|
||||
@@ -110,4 +106,4 @@ lark-cli calendar +room-find \
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md)
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# calendar +rsvp
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:reply"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -38,5 +35,4 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 明确时间分支:room-find + freebusy + 冲突处理
|
||||
|
||||
> 本文档处理**时间已明确**的场景。"明确时间"来源:用户直接表达(如"明天下午3点")、编辑流中已定位日程的原始 start/end、或经用户确认的 suggestion 时间块。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**明确**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 查询会议室(如需)
|
||||
|
||||
若用户需要会议室,先调用 `+room-find`。详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
```
|
||||
|
||||
时间块确定规则:
|
||||
- **编辑流且不改时间,只新增会议室**:`--slot` 必须来自已定位日程的当前 `start/end`
|
||||
- **编辑流且既改时间又加会议室**:`--slot` 必须来自候选新时间,而不是旧时间
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 2. 查询忙闲
|
||||
|
||||
```bash
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
### 3. 冲突处理
|
||||
|
||||
- **无冲突**:直接让用户选择会议室(如需),进入落地操作
|
||||
- **有冲突**:必须先说明冲突情况,询问用户:
|
||||
- **继续当前时间** → 让用户选择会议室(如需),进入落地操作
|
||||
- **换时间** → 转入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 模糊时间 / 无时间信息分支:suggestion + 批量查询
|
||||
|
||||
> 本文档处理**时间模糊**(如"明天下午""下周找个时间")或**完全无时间信息**的场景。核心动作是调用 `+suggestion` 产出候选时间块,再根据是否需要会议室决定后续步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**模糊**或**无时间信息**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 调用 suggestion
|
||||
|
||||
详见 [`lark-calendar-suggestion.md`](./lark-calendar-suggestion.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 用户完全没有提供时间信息时,先默认一个合理区间(如"今天剩余时间"或"近两天")再调用
|
||||
- 编辑流中,若用户说"改到明天下午""下周找个时间再约",基于用户期望的**新时间范围**调用,不要沿用旧时间
|
||||
- **不要在用户完全没给时间时反问"你想约什么时候"** — 先补合理区间再进入 suggestion
|
||||
|
||||
### 2. 分支处理
|
||||
|
||||
#### 不需要会议室
|
||||
|
||||
获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入落地操作。
|
||||
|
||||
#### 需要会议室
|
||||
|
||||
获取候选时间块后,**不要急于让用户只选时间**。先将这些时间块一次性交给 `+room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化展示,让用户一次性完成选择。
|
||||
|
||||
> **注意**:即使用户最初只说"查会议室"且未带时间,也必须强制走 suggestion → room-find 路径。
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 3. 用户确认后
|
||||
|
||||
- 用户选中 `+suggestion` 返回的时间块后,**无需再次调用 `+freebusy`**,直接进入落地操作
|
||||
- **BLOCKING REQUIREMENT**:必须先向用户展示选项并等待确认,禁止在未获用户确认时直接创建/更新日程
|
||||
|
||||
## 模糊语义消解与长期记忆
|
||||
|
||||
针对存在歧义的时间场景,严禁主观臆断。典型例子:
|
||||
- "上班后" / "下班前"
|
||||
- 未明确上下午的 12 小时制时间
|
||||
|
||||
处理规则:
|
||||
- 主动澄清真实意图,不自行猜测
|
||||
- 用户澄清后,将个性化定义沉淀为长期偏好
|
||||
|
||||
## 用户展示格式
|
||||
|
||||
向用户展示多个时间块及对应会议室时,**必须结构化分行排版**,严禁将时间与会议室放在同一行:
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -1,206 +1,95 @@
|
||||
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
||||
|
||||
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
|
||||
## 执行摘要
|
||||
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。
|
||||
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
|
||||
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
|
||||
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。
|
||||
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。
|
||||
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
|
||||
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
|
||||
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
|
||||
|
||||
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。**
|
||||
- **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
|
||||
- **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程。
|
||||
- **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤。
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。
|
||||
- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。
|
||||
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。
|
||||
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。
|
||||
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
|
||||
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
|
||||
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
|
||||
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
|
||||
- **严禁在未读取对应子命令文档前直接调用命令。**
|
||||
- **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`。
|
||||
- **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
|
||||
|
||||
## 适用场景
|
||||
|
||||
- “帮我约个会”
|
||||
- “下周找时间和 XX 开会”
|
||||
- “帮我订个会议室”
|
||||
- “帮我找/搜索一个可用的会议室”
|
||||
- “帮我推荐一个我以前常用的会议室”
|
||||
- “查询明天下午可用的会议室”
|
||||
- “明天下午3点约个日程/日历”
|
||||
- “把明天上午的日程‘产品发布会’加上 小明
|
||||
- “给下周一的周会换个会议室”
|
||||
- “把这个日程改到明天下午,并加上学清 F201”
|
||||
- "帮我约个会" / "下周找时间和 XX 开会"
|
||||
- "帮我订/找/搜索一个可用会议室"
|
||||
- "明天下午3点约个日程"
|
||||
- "把明天上午的日程加上 小明"
|
||||
- "给下周一的周会换个会议室"
|
||||
- "把这个日程改到明天下午,并加上学清 F201"
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。
|
||||
|
||||
## CRITICAL 约束
|
||||
|
||||
- **在调用任何具体的 CLI 子命令(如 `+room-find`、`+suggestion`、`+freebusy`、`+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
|
||||
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。**
|
||||
- **当用户说"查会议室""找会议室",默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
|
||||
## 任务类型判定
|
||||
|
||||
| 类型 | 典型语言信号 | 第一动作 |
|
||||
|------|--------------|----------|
|
||||
| 新建日程 | “约个会”“安排一个会议”“新建日程”“帮我订个会议室开会” | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | “给某个日程加人/删人/加群/加会议室”“把某个日程改到…”“给这场会换个会议室” | 先定位目标日程 `event_id`,再进入后续流程 |
|
||||
| 新建日程 | "约个会""安排会议""新建日程""订个会议室开会" | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | "给某日程加人/删人/加会议室""把某日程改到…""换会议室" | 先定位目标 `event_id` |
|
||||
|
||||
进一步规则:
|
||||
规则:
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`)和**修改动词**(添加、移除、改到、换),默认判定为编辑。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`。
|
||||
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 编辑已有日程:先定位目标日程
|
||||
|
||||
一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。
|
||||
## 编辑流:先定位目标日程
|
||||
|
||||
定位规则:
|
||||
- 优先利用用户给出的标题、日期、时间范围等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围
|
||||
- 命中多个候选日程时,必须向用户展示候选项并要求确认
|
||||
- 重复性日程必须继续定位到该次实例的 `event_id`
|
||||
|
||||
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。
|
||||
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
|
||||
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。
|
||||
编辑流分支路由:
|
||||
|
||||
编辑流分支规则:
|
||||
| 编辑子场景 | 下一步 |
|
||||
|-----------|--------|
|
||||
| 仅增删普通参会人/群组,不改时间,不涉及会议室 | 直接 `+update`(详见 [lark-calendar-update.md](./lark-calendar-update.md)) |
|
||||
| 新增会议室,不改时间 | 基于已定位日程 start/end → [明确时间分支](./lark-calendar-schedule-clear-time.md) |
|
||||
| 只改时间,不涉及会议室 | 判断时间明确性 → 对应分支 |
|
||||
| 既改时间,又新增/更换会议室 | 先确定最终时间 → 再查会议室 → 落地 |
|
||||
|
||||
- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update`。
|
||||
- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。
|
||||
- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。
|
||||
- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。
|
||||
- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`。
|
||||
## 新建日程:智能推断默认值
|
||||
|
||||
### 2. 新建日程:智能推断默认值
|
||||
以下信息智能推断,减少频繁询问用户:
|
||||
- **标题**:根据上下文自动生成;如无法推断,默认"会议"
|
||||
- **参会人**:如未指定,默认仅用户自己
|
||||
- **时长**:基于上下文推断;默认 30 分钟
|
||||
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
|
||||
|
||||
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
|
||||
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
|
||||
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟
|
||||
- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户
|
||||
搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
|
||||
|
||||
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。
|
||||
|
||||
### 3. 判断时间是否明确
|
||||
|
||||
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
|
||||
## 判断时间是否明确
|
||||
|
||||
时间基准规则:
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是明确时间
|
||||
- **编辑流且改时间**:用户想改到的新时间;若表达模糊,进入模糊时间分支
|
||||
**注意**: 在执行修改日程/会议时间的任务时,必须先获取原日程的持续时长。如果用户只提供了新的开始时间,你必须根据原时长自动计算出新的结束时间,严格保持原时长不变,禁止擅自改变原日程的时长。
|
||||
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
|
||||
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`。
|
||||
## 分支路由
|
||||
|
||||
分两类处理:
|
||||
| 判定结果 | 下一步读取 |
|
||||
|----------|-----------|
|
||||
| 明确时间 | [schedule-clear-time.md](./lark-calendar-schedule-clear-time.md) |
|
||||
| 模糊时间 / 无时间信息 | [schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md) |
|
||||
|
||||
- **明确时间**:如“明天下午3点”
|
||||
- **模糊时间**:如“明天下午”“下周找个时间”
|
||||
|
||||
### 4. 明确时间
|
||||
|
||||
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
|
||||
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
|
||||
|
||||
```bash
|
||||
# 1. 如果需要会议室,提前查询会议室
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
|
||||
# 2. 查询当前用户及其他参会人忙闲
|
||||
# (如果有多名参会人,需分别调用查询:--user-id "<ou_xxx>")
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **参会人过多或包含群组时的处理**:
|
||||
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
|
||||
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
|
||||
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`。
|
||||
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
|
||||
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
|
||||
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
|
||||
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city`;`--building` 不要再重复携带城市前缀。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L`、`2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"`;`会议室 02` / `02会议室` -> `--room-name "02"`。
|
||||
- 对 `F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`。
|
||||
|
||||
### 5. 模糊时间或无时间信息
|
||||
|
||||
先调用:
|
||||
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
|
||||
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。
|
||||
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
|
||||
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
|
||||
|
||||
### 6. 模糊语义消解与长期记忆构建
|
||||
|
||||
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
|
||||
|
||||
- “上班后”
|
||||
- “下班前”
|
||||
- 未明确上下午的 12 小时制时间表达
|
||||
|
||||
处理规则:
|
||||
|
||||
- 应主动澄清真实意图,而不是自行猜测
|
||||
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
|
||||
|
||||
### 7. 重复性日程
|
||||
|
||||
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。
|
||||
|
||||
必须检查返回中的:
|
||||
|
||||
- `reserve_until_time`
|
||||
|
||||
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
|
||||
|
||||
- 向用户明确说明该会议室最长可约至何时。
|
||||
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
|
||||
|
||||
### 8. 落地日程变更
|
||||
## 落地日程变更
|
||||
|
||||
用户确认后调用:
|
||||
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。
|
||||
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
```bash
|
||||
lark-cli calendar +create \
|
||||
@@ -214,52 +103,20 @@ lark-cli calendar +update \
|
||||
--start "<start>" \
|
||||
--end "<end>" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
|
||||
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
|
||||
lark-cli calendar +update \
|
||||
--event-id "<event_id>" \
|
||||
--remove-attendee-ids "omm_old_room" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 新建日程时,可使用 `+create`
|
||||
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行;
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。
|
||||
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`。
|
||||
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids "<room_id>"`;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。
|
||||
- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表
|
||||
- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值
|
||||
|
||||
## 用户展示建议
|
||||
|
||||
当向用户展示多个时间块及对应的多个会议室时,**必须使用结构化清晰的格式排版**。**严禁将时间与会议室名称放在同一行展示**,必须分行并使用编号列表呈现可用会议室,严禁将所有信息揉成一团纯文本堆叠。
|
||||
|
||||
**推荐展示格式参考:**
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
落地规则:
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
|
||||
- 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
|
||||
- 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids` 新
|
||||
- 需要会议室时,将选中的 `room_id` 写入参与人列表
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-schedule-clear-time.md](./lark-calendar-schedule-clear-time.md)
|
||||
- [lark-calendar-schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- [lark-calendar-room-find.md](./lark-calendar-room-find.md)
|
||||
- [lark-calendar-freebusy.md](./lark-calendar-freebusy.md)
|
||||
- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md)
|
||||
- [lark-calendar-create.md](./lark-calendar-create.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
- [lark-calendar](../SKILL.md)
|
||||
- [lark-calendar-update.md](./lark-calendar-update.md)
|
||||
- [SKILL.md](../SKILL.md)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
# calendar +search-event
|
||||
|
||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词
|
||||
lark-cli calendar +search-event --query "周会"
|
||||
|
||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
||||
|
||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
||||
|
||||
# 组合
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
||||
@@ -1,6 +1,5 @@
|
||||
# calendar +suggestion
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
|
||||
|
||||
@@ -8,8 +7,6 @@
|
||||
- ✅ **当用户需求涉及寻找时间块,且时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
|
||||
- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -121,5 +118,4 @@ lark-cli calendar +suggestion \
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# calendar +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
更新既有日程字段,或独立增量添加/移除参会人和会议室。
|
||||
|
||||
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -43,7 +40,7 @@ lark-cli calendar +update \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
|
||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
||||
@@ -65,9 +62,9 @@ lark-cli calendar +update \
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`+search-event` 或实例视图定位该实例的 `event_id`。
|
||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
**⚠️ 高风险操作**: 修改时间时必须先读取原日程时长并计算新 end。如果 end 计算错误,会导致日程时长变化,用户会直接感知,禁止擅自改变原日程的时长。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -98,8 +95,6 @@ lark-cli calendar +update \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
|
||||
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) -- 查询忙闲
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
|
||||
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
|
||||
---
|
||||
|
||||
# docs
|
||||
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML(可用 callout、grid、checkbox 等富 block)。
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
@@ -39,9 +39,10 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
@@ -69,6 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||
|
||||
107
skills/lark-doc/references/lark-doc-history.md
Normal file
107
skills/lark-doc/references/lark-doc-history.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# docs history(历史版本与回滚)
|
||||
|
||||
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||
|
||||
## 安全流程
|
||||
|
||||
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。
|
||||
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。
|
||||
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
|
||||
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。
|
||||
5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。
|
||||
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
|
||||
|
||||
## 按 revision_id 或时间点回滚
|
||||
|
||||
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
|
||||
|
||||
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
|
||||
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
|
||||
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
|
||||
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`。
|
||||
|
||||
候选确认时使用类似格式:
|
||||
|
||||
```text
|
||||
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
|
||||
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
|
||||
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出历史版本
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
|
||||
|
||||
# 翻页
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||
|
||||
# 回滚到指定 history_version_id(默认等待 30000ms)
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
|
||||
|
||||
# 只发起任务,不等待
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||
|
||||
# 查询回滚任务状态
|
||||
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|-|-|-|-|
|
||||
| `+history-list` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
|
||||
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
|
||||
| `+history-revert` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
|
||||
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
|
||||
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
|
||||
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
|
||||
|
||||
## 返回值要点
|
||||
|
||||
`+history-list` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"revision_id": 42,
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": 1,
|
||||
"name": "版本名",
|
||||
"description": "版本说明",
|
||||
"editor_ids": ["ou_xxx"]
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"page_token": "page_token"
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"status": "running",
|
||||
"history_version_id": "11",
|
||||
"poll_after_ms": 10000
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert-status` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": ["blk_xxx"]
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
||||
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
|
||||
|
||||
@@ -13,6 +13,13 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
## 统计范围
|
||||
|
||||
先判断用户要求的是**整篇文档**还是**局部内容**:
|
||||
|
||||
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
|
||||
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
@@ -36,6 +43,15 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 字数遵循校验
|
||||
|
||||
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
|
||||
|
||||
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`<y`→`(-∞, y-1]`;`x-y`→`[x, y]`;`x 字左右`→`[round(0.9x), round(1.1x)]`
|
||||
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
|
||||
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
|
||||
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
|
||||
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent(见步骤三)
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
@@ -16,44 +16,32 @@
|
||||
|
||||
## 典型 Code-Act Loop 流程
|
||||
|
||||
### 步骤一:规划与初始创建(串行)
|
||||
### 步骤一:规划与撰写(单 Agent 串行)
|
||||
|
||||
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
||||
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
||||
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
|
||||
3. `docs +create` 创建并撰写:
|
||||
- **短文档**:一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
|
||||
- **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**用 `block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
|
||||
- ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
|
||||
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
|
||||
- ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
|
||||
|
||||
### 步骤二:分段撰写(并行 Agent)
|
||||
### 步骤二:整合审查与画板识别(串行)
|
||||
|
||||
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
|
||||
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
|
||||
- `lark-doc-xml.md` 和 `lark-doc-style.md` 的完整路径(Agent 须先读取)
|
||||
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
|
||||
4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
|
||||
6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
### 步骤三:整合审查与画板识别(串行)
|
||||
### 步骤三:画板处理与润色
|
||||
|
||||
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||
8. 由**主 Agent 自行润色**(不另起内容子 Agent,正文始终一人维护):文字密集且不易读时,优先拆段、加小标题或调整顺序——叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
### 步骤四:画板处理与润色(并行 Agent)
|
||||
### 步骤四:专项校验
|
||||
|
||||
8. **优先处理步骤三识别出的画板需求**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
9. **字数门禁**:如果用户给出任何明确字数要求(如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现目标区间、`word_count` 和达标结论
|
||||
10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现
|
||||
|
||||
@@ -1,86 +1,68 @@
|
||||
# 文档表达组件参考
|
||||
# 飞书文档写作原则
|
||||
|
||||
本文件说明飞书文档可用的结构化表达方式,供模型在需要时选择。它不是固定模板,也不是强制排版规范。
|
||||
写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件。
|
||||
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
|
||||
|
||||
默认原则:优先理解用户目标、受众、素材形态和已有文档风格,由模型自主决定结构、语气和视觉呈现。只有当用户明确要求“美化、重排版、做成报告/方案/看起来更专业”等,或内容本身明显需要结构化承载时,才主动使用下列组件。
|
||||
## 一、用户明确要求优先
|
||||
|
||||
## 一、核心原则
|
||||
用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
|
||||
|
||||
1. **服务内容,而非套模板**:先判断信息最自然的表达方式,再选择段落、列表、表格、分栏、画板等元素
|
||||
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
|
||||
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
|
||||
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
|
||||
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
|
||||
## 二、默认写连贯段落
|
||||
|
||||
## 二、元素选择指南
|
||||
用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
|
||||
|
||||
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
|
||||
| 内容 | 用什么 | ❌ 别 |
|
||||
|---|---|---|
|
||||
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
|
||||
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
|
||||
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
|
||||
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
|
||||
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
|
||||
| 纯短并列项(无描述,如材料清单) | 列表 | — |
|
||||
| 章节(内容成块、需在目录导航) | 标题层级 | — |
|
||||
|
||||
| 场景 | 可选表达方式 |
|
||||
|--------------------------------------------|---------------------------------------|
|
||||
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 |
|
||||
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
|
||||
- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
|
||||
- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
|
||||
- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
|
||||
- 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
|
||||
- **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
|
||||
|
||||
## 三、按体裁写
|
||||
|
||||
### 画板意图识别
|
||||
- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
|
||||
- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block(高亮块、分栏等),粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
|
||||
- **一般文档**:以可读为先,不堆砌结构。
|
||||
|
||||
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化:
|
||||
## 四、编号与层级
|
||||
|
||||
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|
||||
- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
|
||||
- 公文 / 正式材料常用:「一、→(一)→ 1.→(1)」(中文大层级 + 阿拉伯细分层级)。
|
||||
- 学术 / 技术 / 商业报告:「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
|
||||
- ⚠️ **「一、」只能配「(一)」;要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
|
||||
- **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号**;**不跳级**。
|
||||
- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
|
||||
- 简单的 1.2.3 并列项用原生 `<ol><li seq="auto">…</li></ol>` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
|
||||
|
||||
## 五、飞书特有组件,克制使用
|
||||
|
||||
- **高亮块 `<callout>`**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项,全文极少(0~1 个),不要每节导语 / 结论都做成高亮块。
|
||||
- **分栏 `<grid>`**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
|
||||
- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
|
||||
- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
|
||||
|
||||
| 语义 | 背景色 | 文字色 |
|
||||
|-|-|-|
|
||||
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
|
||||
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
|
||||
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
|
||||
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
|
||||
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
|
||||
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
|
||||
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
|
||||
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
|
||||
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
|
||||
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
|
||||
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
|
||||
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
|
||||
| 信息、说明 | `light-blue` | `blue` |
|
||||
| 成功、推荐 | `light-green` | `green` |
|
||||
| 警告 / 错误 / 风险 | `light-red` | `red` |
|
||||
| 注意、待确认 | `light-yellow` | `yellow` |
|
||||
| 中性、辅助 | `light-gray` | — |
|
||||
|
||||
**判断规则:**
|
||||
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
|
||||
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
|
||||
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
|
||||
## 六、写完自检
|
||||
|
||||
## 三、颜色语义
|
||||
|
||||
如果使用颜色,建议保持语义一致;不需要颜色时可以保持朴素文本风格:
|
||||
|
||||
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|
||||
|-|-|-|-|
|
||||
| 信息、说明 | ℹ️ "说明:" | `light-blue` | `blue` |
|
||||
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
|
||||
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
|
||||
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
|
||||
| 中性、辅助 | — | `light-gray` | — |
|
||||
|
||||
- 表头可使用 `background-color="light-gray"`,也可以保持默认样式
|
||||
- 关键指标如使用 `<span text-color="green/red">` 突出,建议同时用 ↑↓ 或 +/- 标注方向(色觉无障碍)
|
||||
|
||||
## 四、排版规范
|
||||
|
||||
- 标题层级、段落长度、列表嵌套和 Grid 列数应以可读性为准,避免过深层级和过宽分栏
|
||||
- 文档开头可以是结论、背景、摘要、问题陈述、目录或直接正文,不强制使用 `<callout>`
|
||||
|
||||
## 五、质量自检
|
||||
|
||||
生成内容后可以从以下角度自检,但不要把这些项当作硬性比例或固定模板:
|
||||
|
||||
| 指标 | 自检问题 |
|
||||
|-|-|
|
||||
| 信息表达 | 当前结构是否符合用户目标,而不是套用固定报告模板? |
|
||||
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多的问题? |
|
||||
| 风格匹配 | 是否延续了用户给定样例或已有文档风格? |
|
||||
| 组件必要性 | callout、grid、table、whiteboard 等是否真的提升理解? |
|
||||
| 保真度 | 改写时是否保留了原文事实、引用、图片、附件和资源块? |
|
||||
交付前快速回看:
|
||||
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
|
||||
- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
|
||||
- **标题是否滥用**:"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
|
||||
- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用(如「一、」配「1.1」)。
|
||||
- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 核心方法论 — Code-Act Loop
|
||||
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent(见步骤二)
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
@@ -23,33 +23,26 @@
|
||||
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
||||
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
|
||||
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 步骤二:定向改写(并行 Agent)
|
||||
### 步骤二:定向改写(单 Agent 串行)
|
||||
|
||||
5. **优先处理步骤一识别出的画板候选段落**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:
|
||||
5. **优先处理步骤一识别出的画板候选段落**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||
6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
|
||||
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
||||
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
|
||||
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
|
||||
- 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`)
|
||||
- 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
|
||||
|
||||
### 步骤三:验证(串行)
|
||||
|
||||
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
|
||||
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果
|
||||
8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
|
||||
|
||||
## Agent 子任务要求
|
||||
### 步骤四:专项校验(按需执行)
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。
|
||||
|
||||
@@ -29,6 +29,7 @@ metadata:
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
||||
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
@@ -36,6 +37,7 @@ metadata:
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;URL 会自动解析类型和 token,Wiki URL 会自动解析到底层 `obj_token/obj_type`。只有手里只有裸 token 时才使用 `--token <TOKEN> --doc-type <doc|docx|sheet|bitable|slides|wiki>`;裸 Wiki token 推荐显式传 `--doc-type wiki`,CLI 会先解析 Wiki node;如果误按底层类型传参且 export task 返回 `file token invalid`,CLI 也会尝试按 Wiki node token 解析并重试一次。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
@@ -102,6 +104,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||
|
||||
### 权限能力入口
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides`(也支持 Wiki URL / Wiki node token 自动解包)导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
@@ -13,6 +13,29 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 推荐:直接传 URL,CLI 自动解析类型和 token
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/docx/<DOCX_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# Wiki URL 也推荐直接传,CLI 会先解析到底层 obj_token/obj_type
|
||||
lark-cli drive +export \
|
||||
--url "https://example.feishu.cn/wiki/<WIKI_NODE_TOKEN>" \
|
||||
--file-extension pdf
|
||||
|
||||
# 只有裸 Wiki node token 时,显式传 --doc-type wiki,让 CLI 先解析到底层文档类型
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type wiki \
|
||||
--file-extension pdf
|
||||
|
||||
# 兼容兜底:如果误把 Wiki token 搭配底层类型传入,且 export task 返回 file token invalid,
|
||||
# CLI 会尝试按 Wiki node token 解析并重试一次;但新脚本仍推荐上面的 --doc-type wiki
|
||||
lark-cli drive +export \
|
||||
--token "<WIKI_NODE_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出新版文档为 pdf,默认保存到当前目录
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -96,8 +119,9 @@ lark-cli drive +export \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--url` | 与 `--token` 二选一 | 源文档 URL,推荐优先使用;CLI 自动解析类型和 token,Wiki URL 会解析到底层 `obj_token/obj_type` |
|
||||
| `--token` | 与 `--url` 二选一 | 源文档裸 token;裸 token 必须同时传 `--doc-type`。裸 Wiki node token 推荐传 `--doc-type wiki`,CLI 会先解析到底层 `obj_token/obj_type`;如误传底层类型并触发 export task `file token invalid`,CLI 会尝试按 Wiki node token 解析并重试一次 |
|
||||
| `--doc-type` | 条件必填 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` / `wiki`;仅当使用裸 `--token` 时必填,使用 `--url` 时自动推断。`wiki` 只用于裸 Wiki node token,解析后会按真实底层类型发起导出 |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
|
||||
@@ -107,12 +131,17 @@ lark-cli drive +export \
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- `pptx` 只支持 `slides`
|
||||
- 推荐优先传 `--url`,不要从 URL 手工拆 token 和 type;尤其是 Wiki URL,CLI 会自动解包到底层资源
|
||||
- `--url` 和 `--token` 互斥
|
||||
- 裸 `--token` 必须传 `--doc-type`;裸 Wiki node token 使用 `--doc-type wiki`
|
||||
- `doc` 支持导出为 `docx` / `pdf`
|
||||
- `docx` 支持导出为 `docx` / `pdf` / `markdown`
|
||||
- `sheet` 支持导出为 `xlsx` / `csv` / `pdf`
|
||||
- `bitable` 支持导出为 `xlsx` / `csv` / `base` / `pdf`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- `csv` 只支持 `sheet` / `bitable`,且必须带 `--sub-id`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- 如果格式不匹配,CLI 会返回 typed validation error,并在 `hint` 中给出可重试的 `--file-extension` 建议;例如 `docx + csv` 会提示改用 `docx/pdf/markdown`,或改传 sheet/bitable URL
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
@@ -121,8 +150,7 @@ lark-cli drive +export \
|
||||
```bash
|
||||
# 第一步:先尝试直接导出
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--url "<DOCX_URL>" \
|
||||
--file-extension pdf \
|
||||
--file-name "weekly-report.pdf"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user