mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
24 Commits
feat/minut
...
v1.0.34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13411d9a51 | ||
|
|
939b7b6fb6 | ||
|
|
a4c5ec99c8 | ||
|
|
7c54f9b023 | ||
|
|
e6bc292575 | ||
|
|
4aa61db8b2 | ||
|
|
28c66be199 | ||
|
|
0e70b056f8 | ||
|
|
95ffff4212 | ||
|
|
e511404065 | ||
|
|
b8469d2dc6 | ||
|
|
afa084e7a4 | ||
|
|
3354494579 | ||
|
|
2bb69d1942 | ||
|
|
c4fb7006d2 | ||
|
|
583349e572 | ||
|
|
315e0ab50c | ||
|
|
ef89d1fd40 | ||
|
|
c8b9809f96 | ||
|
|
de00343063 | ||
|
|
67b16c5ec3 | ||
|
|
7af616b9e5 | ||
|
|
df4b657737 | ||
|
|
4b721c0410 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,9 +34,11 @@ tests/mail/reports/
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
@@ -45,6 +45,7 @@ linters:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- bidichk
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
|
||||
55
CHANGELOG.md
55
CHANGELOG.md
@@ -2,6 +2,59 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.34] - 2026-05-19
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
|
||||
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
|
||||
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
|
||||
- **base**: Support Base attachment APIs (#887)
|
||||
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
|
||||
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identitydiag**: Harden verify path and tighten status semantics (#961)
|
||||
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
|
||||
- **auth**: Split bot and user identity diagnostics (#957)
|
||||
- **base**: Address Base attachment review follow-ups (#958)
|
||||
- **docs**: Clarify `replace_all` selection errors (#954)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify add comment constraints (#967)
|
||||
- **lark-im**: Clarify message activity search (#865)
|
||||
|
||||
### Tests
|
||||
|
||||
- Verify e2e resource cleanup (#949)
|
||||
- **lint**: Exclude `bidichk` from test files (#959)
|
||||
|
||||
## [v1.0.33] - 2026-05-18
|
||||
|
||||
### Features
|
||||
|
||||
- **markdown**: Add `+patch` shortcut (#857)
|
||||
- **slides**: Improve slide planning and validation guidance (#847)
|
||||
- **drive**: Add `+sync` workflow for Drive directories (#873)
|
||||
- **drive**: Add drive version shortcut (#841)
|
||||
- **extension**: Plugin / Hook framework with command pruning (#910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **sheets**: Explicitly document safe JSON unmarshal ignore in `DryRun` (#935)
|
||||
- **base**: Mark base field update high risk (#936)
|
||||
- **auth**: Guide agents to yield during auth device flow (#933)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-wiki**: Correct the `--as` default-identity claim (#919)
|
||||
|
||||
### Tests
|
||||
|
||||
- Drop stale e2e `--yes` flags (#920)
|
||||
|
||||
## [v1.0.32] - 2026-05-15
|
||||
|
||||
### Features
|
||||
@@ -721,6 +774,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
|
||||
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
|
||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
|
||||
@@ -28,7 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
@@ -132,7 +132,7 @@ lark-cli auth status
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -133,7 +133,7 @@ lark-cli auth status
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
|
||||
@@ -5,13 +5,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -60,73 +58,83 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
if config.UserOpenId == "" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
result["identity"] = "bot"
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
status := larkauth.TokenStatus(stored)
|
||||
if status == "expired" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
} else {
|
||||
result["identity"] = "user"
|
||||
}
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["tokenStatus"] = status
|
||||
result["scope"] = stored.Scope
|
||||
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
|
||||
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
|
||||
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
|
||||
|
||||
// --verify: call the server to confirm token is actually usable.
|
||||
if opts.Verify && status != "expired" {
|
||||
verified, verifyErr := verifyTokenOnServer(f, config)
|
||||
result["verified"] = verified
|
||||
if verifyErr != "" {
|
||||
result["verifyError"] = verifyErr
|
||||
}
|
||||
}
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addLegacyUserFields(result, diagnostics.User)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
|
||||
// and calls /authen/v1/user_info to confirm the server accepts it.
|
||||
// Returns (true, "") on success or (false, reason) on failure.
|
||||
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return false, "failed to create HTTP client: " + err.Error()
|
||||
}
|
||||
const (
|
||||
identityUser = "user"
|
||||
identityBot = "bot"
|
||||
identityNone = "none"
|
||||
)
|
||||
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return false, "token unusable: " + err.Error()
|
||||
func effectiveIdentity(d identitydiag.Result) string {
|
||||
switch {
|
||||
case d.User.Available:
|
||||
return identityUser
|
||||
case d.Bot.Available:
|
||||
return identityBot
|
||||
default:
|
||||
return identityNone
|
||||
}
|
||||
}
|
||||
|
||||
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||
if user.OpenID == "" {
|
||||
return
|
||||
}
|
||||
result["userName"] = user.UserName
|
||||
result["userOpenId"] = user.OpenID
|
||||
if user.TokenStatus != "" {
|
||||
result["tokenStatus"] = user.TokenStatus
|
||||
}
|
||||
if user.Scope != "" {
|
||||
result["scope"] = user.Scope
|
||||
}
|
||||
if user.ExpiresAt != "" {
|
||||
result["expiresAt"] = user.ExpiresAt
|
||||
}
|
||||
if user.RefreshExpiresAt != "" {
|
||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||
}
|
||||
if user.GrantedAt != "" {
|
||||
result["grantedAt"] = user.GrantedAt
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
if d.User.Verified != nil {
|
||||
result["verified"] = *d.User.Verified
|
||||
if !*d.User.Verified {
|
||||
result["verifyError"] = d.User.Message
|
||||
}
|
||||
}
|
||||
case identityBot:
|
||||
if d.Bot.Verified != nil {
|
||||
result["verified"] = *d.Bot.Verified
|
||||
if !*d.Bot.Verified {
|
||||
result["verifyError"] = d.Bot.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
|
||||
case d.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
|
||||
case !d.User.Available && !d.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
}
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return false, "failed to create SDK client: " + err.Error()
|
||||
}
|
||||
|
||||
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
|
||||
return false, "server rejected token: " + err.Error()
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
96
cmd/auth/status_test.go
Normal file
96
cmd/auth/status_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Verified == nil || !*got.Verified {
|
||||
t.Fatalf("verified = %v, want true", got.Verified)
|
||||
}
|
||||
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
|
||||
}
|
||||
if got.Identities.Bot.OpenID != "ou_bot" {
|
||||
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" {
|
||||
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
Identities struct {
|
||||
Bot statusIdentity `json:"bot"`
|
||||
User statusIdentity `json:"user"`
|
||||
} `json:"identities"`
|
||||
}
|
||||
|
||||
type statusIdentity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified"`
|
||||
OpenID string `json:"openId"`
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
@@ -51,7 +51,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
// checkResult represents one diagnostic check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "pass", "fail", "skip"
|
||||
Status string `json:"status"` // "pass", "warn", "fail", "skip"
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
@@ -118,59 +118,31 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Token exists ──
|
||||
if cfg.UserOpenId == "" {
|
||||
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
|
||||
|
||||
// ── 4. Token local validity ──
|
||||
status := larkauth.TokenStatus(stored)
|
||||
switch status {
|
||||
case "valid":
|
||||
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
|
||||
case "needs_refresh":
|
||||
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
|
||||
default: // expired
|
||||
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
// ── 5. Token server verification ──
|
||||
if opts.Offline {
|
||||
checks = append(checks, skip("token_verified", "skipped (--offline)"))
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
httpClient := mustHTTPClient(f)
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
|
||||
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
|
||||
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 6 & 7. Endpoint reachability ──
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
if id.Available {
|
||||
return pass(name, id.Message)
|
||||
}
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -232,15 +204,6 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustHTTPClient returns f.HttpClient() or a default client.
|
||||
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
c, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
|
||||
@@ -95,3 +95,59 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "bot_identity", "pass")
|
||||
assertCheck(t, got.Checks, "user_identity", "warn")
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -66,3 +67,49 @@ func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TC-10: AuthTypes=["user"] → usage contains "identity type: user" and NOT "bot".
|
||||
func TestAddShortcutIdentityFlag_UserOnlyAuthTypes(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user"})
|
||||
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible")
|
||||
}
|
||||
wantUsage := "identity type: user"
|
||||
if flag.Usage != wantUsage {
|
||||
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
|
||||
}
|
||||
if strings.Contains(flag.Usage, "bot") {
|
||||
t.Errorf("Usage should not contain \"bot\" for user-only shortcut, got %q", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TC-11: AuthTypes=["user","bot"] → usage == "identity type: user | bot".
|
||||
func TestAddShortcutIdentityFlag_UserBotAuthTypes(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user", "bot"})
|
||||
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible")
|
||||
}
|
||||
if got := flag.DefValue; got != "" {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
wantUsage := "identity type: user | bot"
|
||||
if flag.Usage != wantUsage {
|
||||
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
|
||||
}
|
||||
}
|
||||
|
||||
325
internal/identitydiag/diagnostics.go
Normal file
325
internal/identitydiag/diagnostics.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package identitydiag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusReady = "ready"
|
||||
StatusNotConfigured = "not_configured"
|
||||
StatusMissing = "missing"
|
||||
StatusNeedsRefresh = "needs_refresh"
|
||||
StatusVerifyFailed = "verify_failed"
|
||||
)
|
||||
|
||||
// verifyTimeout bounds each network call made during --verify so that a
|
||||
// hanging server cannot wedge `auth status --verify` or `doctor`. Mirrors
|
||||
// the 10s timeout used by the doctor endpoint probe.
|
||||
const verifyTimeout = 10 * time.Second
|
||||
|
||||
// Result describes the independently usable bot and user identities.
|
||||
type Result struct {
|
||||
Bot Identity `json:"bot"`
|
||||
User Identity `json:"user"`
|
||||
}
|
||||
|
||||
// Identity is a single identity diagnostic result.
|
||||
type Identity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
AppName string `json:"appName,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpiresAt string `json:"refreshExpiresAt,omitempty"`
|
||||
GrantedAt string `json:"grantedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Diagnose checks bot and user identities separately. When verify is false,
|
||||
// it only reports local readiness and skips server calls.
|
||||
func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Result {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return Result{
|
||||
Bot: diagnoseBot(ctx, f, cfg, verify),
|
||||
User: diagnoseUser(ctx, f, cfg, verify),
|
||||
}
|
||||
}
|
||||
|
||||
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
if !cfg.CanBot() {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (bot identity is not available in current credential context)",
|
||||
Hint: "check strict mode or the active credential provider",
|
||||
}
|
||||
}
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app secret or bot token)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
Status: StatusReady,
|
||||
Available: true,
|
||||
Message: "Bot identity: ready",
|
||||
}
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
|
||||
token, err := resolveBotToken(ctx, f, cfg)
|
||||
if err != nil {
|
||||
status := StatusVerifyFailed
|
||||
var unavailable *credential.TokenUnavailableError
|
||||
if errors.As(err, &unavailable) {
|
||||
status = StatusNotConfigured
|
||||
}
|
||||
return Identity{
|
||||
Status: status,
|
||||
Verified: boolPtr(false),
|
||||
Message: "Bot identity: " + StatusMessage(status) + ": " + err.Error(),
|
||||
Hint: "check app credentials or the active credential provider",
|
||||
}
|
||||
}
|
||||
|
||||
info, err := fetchBotInfo(ctx, f, cfg, token)
|
||||
if err != nil {
|
||||
return Identity{
|
||||
Status: StatusVerifyFailed,
|
||||
Verified: boolPtr(false),
|
||||
Message: "Bot identity: verify failed: " + err.Error(),
|
||||
Hint: "check app credentials, scopes, network, or tenant access token configuration",
|
||||
}
|
||||
}
|
||||
|
||||
id.Verified = boolPtr(true)
|
||||
id.OpenID = info.OpenID
|
||||
id.AppName = info.AppName
|
||||
return id
|
||||
}
|
||||
|
||||
func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "User identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return Identity{
|
||||
Status: StatusMissing,
|
||||
Message: "User identity: missing (no user logged in)",
|
||||
Hint: "run: lark-cli auth login --help",
|
||||
}
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
UserName: cfg.UserName,
|
||||
OpenID: cfg.UserOpenId,
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
id.Status = StatusMissing
|
||||
id.Message = "User identity: missing (no token in keychain for " + cfg.UserOpenId + ")"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
}
|
||||
|
||||
fillTokenFields(&id, stored)
|
||||
switch larkauth.TokenStatus(stored) {
|
||||
case "valid":
|
||||
id.Status = StatusReady
|
||||
id.Available = true
|
||||
id.Message = "User identity: ready"
|
||||
case "needs_refresh":
|
||||
id.Status = StatusNeedsRefresh
|
||||
id.Available = true
|
||||
id.Message = "User identity: needs refresh (will auto-refresh on next user API call)"
|
||||
default:
|
||||
id.Status = StatusMissing
|
||||
id.Message = "User identity: missing (refresh token expired)"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
}
|
||||
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
|
||||
markVerifyFailed := func(reason, hint string) Identity {
|
||||
id.Status = StatusVerifyFailed
|
||||
id.Available = false
|
||||
id.Verified = boolPtr(false)
|
||||
id.Message = "User identity: verify failed: " + reason
|
||||
if hint != "" {
|
||||
id.Hint = hint
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return markVerifyFailed("create HTTP client: "+err.Error(), "")
|
||||
}
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help")
|
||||
}
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return markVerifyFailed("SDK init failed: "+err.Error(), "")
|
||||
}
|
||||
verifyCtx, cancel := context.WithTimeout(ctx, verifyTimeout)
|
||||
defer cancel()
|
||||
if err := larkauth.VerifyUserToken(verifyCtx, sdk, token); err != nil {
|
||||
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help")
|
||||
}
|
||||
|
||||
id.Verified = boolPtr(true)
|
||||
if id.Status == StatusReady {
|
||||
id.Message = "User identity: ready"
|
||||
} else {
|
||||
id.Message = "User identity: needs refresh (server verification succeeded after refresh)"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig) (string, error) {
|
||||
if f == nil || f.Credential == nil {
|
||||
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, cfg.AppID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
type botInfo struct {
|
||||
OpenID string
|
||||
AppName string
|
||||
}
|
||||
|
||||
func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, token string) (*botInfo, error) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create HTTP client: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
|
||||
defer cancel()
|
||||
url := strings.TrimRight(core.ResolveEndpoints(cfg.Brand).Open, "/") + "/open-apis/bot/v3/info"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
||||
// payload is under "bot", not "data" as the newer Lark API convention.
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"bot"`
|
||||
}
|
||||
parseErr := json.Unmarshal(body, &envelope)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
// Lark error responses are usually `{code, msg}` envelopes even on
|
||||
// non-2xx — surface them when present so callers see why bot auth
|
||||
// was rejected, not just the bare HTTP code.
|
||||
if parseErr == nil && envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("HTTP %d: [%d] %s", resp.StatusCode, envelope.Code, envelope.Msg)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", parseErr)
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("[%d] %s", envelope.Code, envelope.Msg)
|
||||
}
|
||||
if envelope.Data.OpenID == "" {
|
||||
return nil, errors.New("open_id is empty")
|
||||
}
|
||||
return &botInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
|
||||
}
|
||||
|
||||
func fillTokenFields(id *Identity, token *larkauth.StoredUAToken) {
|
||||
id.TokenStatus = larkauth.TokenStatus(token)
|
||||
id.Scope = token.Scope
|
||||
id.ExpiresAt = formatMillis(token.ExpiresAt)
|
||||
id.RefreshExpiresAt = formatMillis(token.RefreshExpiresAt)
|
||||
id.GrantedAt = formatMillis(token.GrantedAt)
|
||||
}
|
||||
|
||||
func formatMillis(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func StatusMessage(status string) string {
|
||||
switch status {
|
||||
case StatusNotConfigured:
|
||||
return "not configured"
|
||||
case StatusVerifyFailed:
|
||||
return "verify failed"
|
||||
case StatusNeedsRefresh:
|
||||
return "needs refresh"
|
||||
case StatusMissing:
|
||||
return "missing"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
350
internal/identitydiag/diagnostics_test.go
Normal file
350
internal/identitydiag/diagnostics_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package identitydiag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func TestDiagnose_NoUserReportsBotReadyAndUserMissing(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusReady || !got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Bot)
|
||||
}
|
||||
if got.User.Status != StatusMissing || got.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_BotIdentityNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
stub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusReady || !got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Bot)
|
||||
}
|
||||
if got.Bot.Verified == nil || !*got.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Bot.Verified)
|
||||
}
|
||||
if got.Bot.OpenID != "ou_bot" || got.Bot.AppName != "diagnostic bot" {
|
||||
t.Fatalf("bot info = %#v, want open id and app name", got.Bot)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyUserIdentity(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-user",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Status != StatusReady || !got.User.Available {
|
||||
t.Fatalf("user = %#v, want ready and available", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || !*got.User.Verified {
|
||||
t.Fatalf("user verified = %v, want true", got.User.Verified)
|
||||
}
|
||||
if got.User.OpenID != "ou_user" || got.User.UserName != "tester" {
|
||||
t.Fatalf("user = %#v, want user identity details", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity_HTTPErrorSurfacesEnvelope(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: http.StatusUnauthorized,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663,
|
||||
"msg": "app ticket invalid",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
||||
}
|
||||
if got.Bot.Verified == nil || *got.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want false", got.Bot.Verified)
|
||||
}
|
||||
if !strings.Contains(got.Bot.Message, "401") || !strings.Contains(got.Bot.Message, "99991663") {
|
||||
t.Fatalf("bot message = %q, want both HTTP code and envelope code", got.Bot.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity_BusinessErrorCode(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10013,
|
||||
"msg": "scope not granted",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
||||
}
|
||||
if !strings.Contains(got.Bot.Message, "10013") || !strings.Contains(got.Bot.Message, "scope not granted") {
|
||||
t.Fatalf("bot message = %q, want envelope code/msg", got.Bot.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyUserIdentity_ServerRejects(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-reject",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"bot": map[string]interface{}{"open_id": "ou_bot", "app_name": "bot"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991661,
|
||||
"msg": "access token invalid",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Status != StatusVerifyFailed || got.User.Available {
|
||||
t.Fatalf("user = %#v, want verify_failed and unavailable", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || *got.User.Verified {
|
||||
t.Fatalf("user verified = %v, want false", got.User.Verified)
|
||||
}
|
||||
if !strings.Contains(got.User.Message, "server rejected token") {
|
||||
t.Fatalf("user message = %q, want 'server rejected token'", got.User.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityExpired(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-expired",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_expired",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(-time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(-time.Minute).UnixMilli(),
|
||||
GrantedAt: now.Add(-24 * time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusMissing || got.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.User)
|
||||
}
|
||||
if got.User.Hint == "" {
|
||||
t.Fatalf("user hint is empty, want re-login hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_BotIdentityStrictUserOnly(t *testing.T) {
|
||||
// SupportedIdentities = SupportsUser (1) only — bot path should be
|
||||
// reported as not_configured even though an app secret is present.
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
SupportedIdentities: 1,
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityMissingAppConfig(t *testing.T) {
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusNotConfigured || got.User.Available {
|
||||
t.Fatalf("user = %#v, want not_configured and unavailable", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMessage(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
StatusReady: StatusReady,
|
||||
StatusNotConfigured: "not configured",
|
||||
StatusVerifyFailed: "verify failed",
|
||||
StatusNeedsRefresh: "needs refresh",
|
||||
StatusMissing: "missing",
|
||||
"unknown": "unknown",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := StatusMessage(in); got != want {
|
||||
t.Errorf("StatusMessage(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-needs-refresh",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_refresh",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Minute).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusNeedsRefresh || !got.User.Available {
|
||||
t.Fatalf("user = %#v, want needs_refresh and available", got.User)
|
||||
}
|
||||
if got.User.TokenStatus != "needs_refresh" {
|
||||
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.32",
|
||||
"version": "1.0.34",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -149,29 +149,26 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
|
||||
|
||||
uploadAttachmentRT := newBaseTestRuntime(
|
||||
uploadAttachmentRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"record-id": "rec_1",
|
||||
"field-id": "fld_att",
|
||||
"file": "/tmp/report.pdf",
|
||||
"name": "report-final.pdf",
|
||||
},
|
||||
map[string][]string{"file": {"/tmp/report.pdf"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t,
|
||||
BaseRecordUploadAttachment.DryRun(ctx, uploadAttachmentRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_att",
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"POST /open-apis/drive/v1/medias/upload_all",
|
||||
"bitable_file",
|
||||
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"report-final.pdf",
|
||||
`"mime_type":"\u003cdetected_mime_type\u003e"`,
|
||||
`"size":"\u003cfile_size\u003e"`,
|
||||
"deprecated_set_attachment",
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/append_attachments",
|
||||
"report.pdf",
|
||||
`"image_width":"\u003cimage_width_if_image\u003e"`,
|
||||
`"image_height":"\u003cimage_height_if_image\u003e"`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1589,12 +1595,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Run("upload attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.txt")
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.png")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString("hello attachment"); err != nil {
|
||||
t.Fatalf("WriteString() err=%v", err)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
||||
img.Set(0, 0, color.RGBA{R: 255, A: 255})
|
||||
if err := png.Encode(tmpFile, img); err != nil {
|
||||
t.Fatalf("png.Encode() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
@@ -1609,28 +1617,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
@@ -1640,34 +1626,27 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "report.txt",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "base-attachment.png",
|
||||
"size": 73,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1676,11 +1655,10 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "report.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_1"`) || !strings.Contains(got, `"report.txt"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_1"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
@@ -1689,19 +1667,13 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("upload body=%s", uploadBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"existing_tok"`) ||
|
||||
!strings.Contains(updateBody, `"name":"existing.pdf"`) ||
|
||||
!strings.Contains(updateBody, `"size":2048`) ||
|
||||
!strings.Contains(updateBody, `"image_width":640`) ||
|
||||
!strings.Contains(updateBody, `"image_height":480`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
|
||||
!strings.Contains(updateBody, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(appendBody, `"image_width":3`) ||
|
||||
!strings.Contains(appendBody, `"image_height":2`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1728,17 +1700,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -1778,26 +1739,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1806,17 +1764,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_big"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
if !strings.Contains(prepareBody, `"file_name":"`+filepath.Base(tmpFile.Name())+`"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
@@ -1847,14 +1804,11 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"size":20971521`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_big"`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1928,6 +1882,434 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-name-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "renamed.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--name is no longer supported") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download attachment includes extra query parameter", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
extra := `{"bitablePerm":{"tableId":"tbl_x","attachments":{"fld_att":{"rec_x":["box_a"]}}}}`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "box_a",
|
||||
"name": "pic.png",
|
||||
"size": 7,
|
||||
"extra_info": extra,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
downloadStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download?" + url.Values{"extra": []string{extra}}.Encode(),
|
||||
RawBody: []byte("payload"),
|
||||
ContentType: "image/png",
|
||||
}
|
||||
reg.Register(downloadStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "pic.png")); err != nil {
|
||||
t.Fatalf("expected downloaded file: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
got, _ := gotItems[0].(map[string]interface{})
|
||||
if got["file_token"] != "box_a" || got["saved_path"] == "" || got["extra_info_used"] != nil {
|
||||
t.Fatalf("download output=%#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all row attachments when file token omitted", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download without file token requires output directory", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "file.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output must be an existing directory") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "same.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download duplicate requested file token only once", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_a",
|
||||
"--output", "a.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all preflights local target conflicts before writing", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("downloads", "b.txt"), []byte("existing"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "output file already exists: downloads/b.txt") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err == nil {
|
||||
t.Fatalf("a.txt should not be written after preflight conflict")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
Status: 500,
|
||||
RawBody: []byte("server error"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
downloaded, _ := detail["downloaded"].([]map[string]interface{})
|
||||
failed, _ := detail["failed"].([]map[string]interface{})
|
||||
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
|
||||
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected first file to remain: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remove attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
removeStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{"fld_att": []interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(removeStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordRemoveAttachment, []string{
|
||||
"+record-remove-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_b",
|
||||
"--yes",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, `"removed"`) || strings.Contains(got, `"updated"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(removeStub.CapturedBody)
|
||||
if !strings.Contains(body, `"rec_x"`) ||
|
||||
!strings.Contains(body, `"fld_att"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_a"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_b"`) {
|
||||
t.Fatalf("remove body=%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-delete",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
|
||||
"+record-history-list",
|
||||
"+base-get", "+base-copy", "+base-create",
|
||||
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
|
||||
@@ -175,14 +175,15 @@ func TestBaseFieldUpdateRisk(t *testing.T) {
|
||||
|
||||
func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
BaseFieldDelete.Command: BaseFieldDelete.Risk,
|
||||
BaseViewDelete.Command: BaseViewDelete.Risk,
|
||||
BaseRecordDelete.Command: BaseRecordDelete.Risk,
|
||||
BaseFormDelete.Command: BaseFormDelete.Risk,
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
BaseFieldDelete.Command: BaseFieldDelete.Risk,
|
||||
BaseViewDelete.Command: BaseViewDelete.Risk,
|
||||
BaseRecordDelete.Command: BaseRecordDelete.Risk,
|
||||
BaseRecordRemoveAttachment.Command: BaseRecordRemoveAttachment.Risk,
|
||||
BaseFormDelete.Command: BaseFormDelete.Risk,
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
for command, risk := range cases {
|
||||
@@ -338,6 +339,79 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "upload attachment",
|
||||
shortcut: BaseRecordUploadAttachment,
|
||||
wantHelp: []string{
|
||||
"repeat to append multiple attachments in one cell",
|
||||
"max 50 files, max 2GB each",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-upload-attachment",
|
||||
"Repeat --file to append multiple attachments",
|
||||
"Reuse returned file_token values for download/remove",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "download attachment",
|
||||
shortcut: BaseRecordDownloadAttachment,
|
||||
wantHelp: []string{
|
||||
"repeat to download selected files",
|
||||
"omit to download all attachments in the record",
|
||||
"with multiple or omitted file tokens this must be an existing directory",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-download-attachment",
|
||||
"Omit --file-token to download every attachment in the record",
|
||||
"Base attachments should be downloaded with this command",
|
||||
"other download commands may fail",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove attachment",
|
||||
shortcut: BaseRecordRemoveAttachment,
|
||||
wantHelp: []string{
|
||||
"remove from the target cell",
|
||||
"max 50 tokens",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-remove-attachment",
|
||||
"Repeat --file-token",
|
||||
"requires --yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOrder(t *testing.T, help string, before string, after string) {
|
||||
t.Helper()
|
||||
beforeIndex := strings.Index(help, before)
|
||||
|
||||
@@ -8,27 +8,37 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseAttachmentMaxBatchSize = 50
|
||||
baseAttachmentGetMaxRecords = 10
|
||||
)
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-upload-attachment",
|
||||
Description: "Upload a local file to a Base attachment field and write it into the target record",
|
||||
Description: "Upload one or more local files and append the returned file_token values to a Base attachment cell",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
@@ -37,34 +47,99 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
{Name: "file", Type: "string_array", Desc: "local file path; repeat to append multiple attachments in one cell; max 50 files, max 2GB each; files > 20MB use multipart upload automatically", Required: true},
|
||||
{Name: "name", Desc: "deprecated; attachment names are derived from local file basenames", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-upload-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file ./report.pdf`,
|
||||
`Repeat --file to append multiple attachments: --file ./report.pdf --file ./screenshot.png`,
|
||||
`Reuse returned file_token values for download/remove`,
|
||||
},
|
||||
DryRun: dryRunRecordUploadAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordUploadAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUploadAttachment(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseRecordDownloadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-download-attachment",
|
||||
Description: "Download Base record attachments by record-id, optionally filtering by file-token",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read", "docs:document.media:download"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token returned by Base; repeat to download selected files; omit to download all attachments in the record", Required: false},
|
||||
{Name: "output", Desc: "local save path; with exactly one file token this may be a file path; with multiple or omitted file tokens this must be an existing directory", Required: true},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-download-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --file-token <file_token> --output ./downloads/`,
|
||||
`Omit --file-token to download every attachment in the record.`,
|
||||
`Base attachments should be downloaded with this command; other download commands may fail for Base attachment files.`,
|
||||
`With one --file-token, --output may be a file path or directory; with multiple or omitted --file-token values, --output must be an existing directory.`,
|
||||
},
|
||||
DryRun: dryRunRecordDownloadAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordDownloadAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordDownloadAttachment(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseRecordRemoveAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-remove-attachment",
|
||||
Description: "Remove one or more file_token values from a Base record attachment cell",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:record:update", "base:field:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
|
||||
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
||||
`This is a high-risk write command and requires --yes.`,
|
||||
},
|
||||
DryRun: dryRunRecordRemoveAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordRemoveAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordRemoveAttachment(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
filePath := runtime.Str("file")
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
files := runtime.StrArray("file")
|
||||
filePath := "<file>"
|
||||
fileName := "<local_file_name>"
|
||||
if len(files) > 0 {
|
||||
filePath = files[0]
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
Desc("3-step orchestration: validate attachment field → upload local file(s) to Base → append uploaded file token(s) to the attachment cell").
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Desc("[1] Read target field and ensure it is an attachment field").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("field_id", runtime.Str("field-id")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("field_id", runtime.Str("field-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Desc("[2a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
@@ -72,7 +147,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
Desc("[2b] Upload attachment parts (repeated for each large file)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
@@ -80,14 +155,14 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
Desc("[2c] Finalize multipart attachment upload and get file token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
} else {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Desc("[2] Upload local file(s) to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
@@ -97,46 +172,87 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/append_attachments").
|
||||
Desc("[3] Append uploaded file token(s) to the target attachment cell").
|
||||
Body(map[string]interface{}{
|
||||
"<attachment_field_name>": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "<existing_file_token>",
|
||||
"name": "<existing_file_name>",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"name": fileName,
|
||||
"mime_type": "<detected_mime_type>",
|
||||
"size": "<file_size>",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
runtime.Str("record-id"): map[string]interface{}{
|
||||
runtime.Str("field-id"): []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"image_width": "<image_width_if_image>",
|
||||
"image_height": "<image_height_if_image>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
filePath := runtime.Str("file")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
func dryRunRecordDownloadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: read Base attachment metadata → download each requested attachment file").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/get_attachments").
|
||||
Desc("[1] Read attachment metadata for the record").
|
||||
Body(map[string]interface{}{"record_id_list": []string{runtime.Str("record-id")}}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
GET("/open-apis/drive/v1/medias/:file_token/download").
|
||||
Desc("[2] Download attachment media through the Base attachment flow").
|
||||
Set("file_token", "<file_token>").
|
||||
Set("output", runtime.Str("output")).
|
||||
Params(map[string]interface{}{"extra": "<extra_info_if_present>"})
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), runtime.Str("field-id"), fileTokenPatchItems(runtime.StrArray("file-token")))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/remove_attachments").
|
||||
Desc("Remove attachment file token(s) from the target attachment cell").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("name") {
|
||||
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||
}
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range files {
|
||||
if _, err := validateAttachmentInputFile(runtime, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
||||
if statErr != nil || !info.IsDir() {
|
||||
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
_, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
return err
|
||||
}
|
||||
|
||||
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
||||
@@ -146,44 +262,172 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
resolvedFieldID = runtime.Str("field-id")
|
||||
}
|
||||
|
||||
record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-id"))
|
||||
appendItems := make([]interface{}, 0, len(files))
|
||||
for _, filePath := range files {
|
||||
fileInfo, err := validateAttachmentInputFile(runtime, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appendItems = append(appendItems, attachmentAppendItem(attachment))
|
||||
}
|
||||
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, appendItems)
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "append_attachments"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attachments, err := mergeRecordAttachments(record, fieldName(field), attachment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
fieldName(field): attachments,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"record": data,
|
||||
"attachment": attachment,
|
||||
"attachments": attachments,
|
||||
"updated": true,
|
||||
}, nil)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
resolvedFieldID = runtime.Str("field-id")
|
||||
}
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, fileTokenPatchItems(tokens))
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "remove_attachments"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordDownloadAttachment(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := fetchBaseAttachments(runtime, runtime.Str("base-token"), baseTableID(runtime), []string{runtime.Str("record-id")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := selectAttachmentDownloadItems(attachments, runtime.Str("record-id"), tokens)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := planAttachmentDownloadTargets(runtime, items, runtime.Str("output"), len(tokens) != 1 || len(items) > 1, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
downloaded := make([]map[string]interface{}, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
failed := attachmentDownloadFailure(target, err)
|
||||
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
|
||||
}
|
||||
downloaded = append(downloaded, saved)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"downloaded": downloaded}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return nil, output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return nil, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil, output.ErrValidation("file path is a directory: %s", filePath)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func normalizeAttachmentFiles(files []string) ([]string, error) {
|
||||
return normalizeStringList(files, stringListNormalizeOptions{
|
||||
typeError: "attachment files must be a string array",
|
||||
emptyError: "provide at least one --file",
|
||||
itemName: "attachment file",
|
||||
duplicateName: "attachment file",
|
||||
limitName: "attachment file count",
|
||||
max: baseAttachmentMaxBatchSize,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeAttachmentFileTokens(tokens []string) ([]string, error) {
|
||||
return normalizeStringList(tokens, stringListNormalizeOptions{
|
||||
typeError: "attachment file tokens must be a string array",
|
||||
emptyError: "provide at least one --file-token",
|
||||
itemName: "attachment file token",
|
||||
duplicateName: "attachment file token",
|
||||
limitName: "attachment file token count",
|
||||
max: baseAttachmentMaxBatchSize,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, error) {
|
||||
if len(tokens) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
normalized := make([]string, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
}
|
||||
normalized = append(normalized, token)
|
||||
}
|
||||
normalized = dedupeStringsPreserveOrder(normalized)
|
||||
if len(normalized) > baseAttachmentMaxBatchSize {
|
||||
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func dedupeStringsPreserveOrder(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
if fio == nil {
|
||||
return false
|
||||
}
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -195,57 +439,24 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
|
||||
func fetchBaseRecord(runtime *common.RuntimeContext, baseToken, tableIDValue, recordID string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, nil)
|
||||
}
|
||||
|
||||
func mergeRecordAttachments(record map[string]interface{}, fieldName string, uploaded map[string]interface{}) ([]interface{}, error) {
|
||||
fields, _ := record["fields"].(map[string]interface{})
|
||||
if fields == nil {
|
||||
return []interface{}{uploaded}, nil
|
||||
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
||||
if len(recordIDs) == 0 {
|
||||
return nil, output.ErrValidation("provide at least one record id")
|
||||
}
|
||||
current, exists := fields[fieldName]
|
||||
if !exists || util.IsNil(current) {
|
||||
return []interface{}{uploaded}, nil
|
||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
}
|
||||
items, ok := current.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current)
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
||||
"record_id_list": recordIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := make([]interface{}, 0, len(items)+1)
|
||||
for _, item := range items {
|
||||
attachment, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q contains unexpected attachment item type %T", fieldName, item)
|
||||
}
|
||||
merged = append(merged, normalizeAttachmentForPatch(attachment))
|
||||
attachments, _ := data["attachments"].(map[string]interface{})
|
||||
if attachments == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
merged = append(merged, uploaded)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} {
|
||||
normalized := map[string]interface{}{}
|
||||
if fileToken, _ := attachment["file_token"].(string); fileToken != "" {
|
||||
normalized["file_token"] = fileToken
|
||||
}
|
||||
if name, _ := attachment["name"].(string); name != "" {
|
||||
normalized["name"] = name
|
||||
}
|
||||
if mimeType, _ := attachment["mime_type"].(string); mimeType != "" {
|
||||
normalized["mime_type"] = mimeType
|
||||
}
|
||||
if size, ok := attachment["size"]; ok && !util.IsNil(size) {
|
||||
normalized["size"] = size
|
||||
}
|
||||
if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) {
|
||||
normalized["image_width"] = imageWidth
|
||||
}
|
||||
if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) {
|
||||
normalized["image_height"] = imageHeight
|
||||
}
|
||||
normalized["deprecated_set_attachment"] = true
|
||||
return normalized
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
@@ -280,15 +491,51 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
"deprecated_set_attachment": true,
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
}
|
||||
if width, height, ok := detectAttachmentImageDimensions(runtime.FileIO(), filePath, mimeType); ok {
|
||||
attachment["image_width"] = width
|
||||
attachment["image_height"] = height
|
||||
} else if attachmentImageDimensionsWarningEnabled(mimeType) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Warning: image dimensions unavailable for %s; attachment may display as square\n", fileName)
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func attachmentAppendItem(attachment map[string]interface{}) map[string]interface{} {
|
||||
item := map[string]interface{}{
|
||||
"file_token": attachment["file_token"],
|
||||
}
|
||||
if width, ok := attachment["image_width"]; ok && !util.IsNil(width) {
|
||||
item["image_width"] = width
|
||||
}
|
||||
if height, ok := attachment["image_height"]; ok && !util.IsNil(height) {
|
||||
item["image_height"] = height
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func fileTokenPatchItems(tokens []string) []interface{} {
|
||||
items := make([]interface{}, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
items = append(items, map[string]interface{}{"file_token": token})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildSingleCellAttachmentsBody(recordID, fieldID string, items []interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
recordID: map[string]interface{}{
|
||||
fieldID: items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
@@ -311,6 +558,309 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
|
||||
func detectAttachmentImageDimensions(fio fileio.FileIO, filePath string, mimeType string) (int, int, bool) {
|
||||
if fio == nil || !strings.HasPrefix(mimeType, "image/") {
|
||||
return 0, 0, false
|
||||
}
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
func attachmentImageDimensionsWarningEnabled(mimeType string) bool {
|
||||
switch mimeType {
|
||||
case "image/gif", "image/jpeg", "image/png":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type baseAttachmentDownloadItem struct {
|
||||
RecordID string
|
||||
FieldID string
|
||||
FileToken string
|
||||
Name string
|
||||
Size interface{}
|
||||
ExtraInfo string
|
||||
MimeType string
|
||||
RawPayload map[string]interface{}
|
||||
}
|
||||
|
||||
type baseAttachmentDownloadTarget struct {
|
||||
Item baseAttachmentDownloadItem
|
||||
TargetPath string
|
||||
ResolvedPath string
|
||||
}
|
||||
|
||||
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
||||
recordRaw, ok := attachments[recordID]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
}
|
||||
fields, ok := recordRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||
}
|
||||
byToken := map[string]baseAttachmentDownloadItem{}
|
||||
fieldIDs := make([]string, 0, len(fields))
|
||||
for currentFieldID := range fields {
|
||||
fieldIDs = append(fieldIDs, currentFieldID)
|
||||
}
|
||||
sort.Strings(fieldIDs)
|
||||
for _, currentFieldID := range fieldIDs {
|
||||
rawList := fields[currentFieldID]
|
||||
items, ok := rawList.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||
}
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||
}
|
||||
fileToken, _ := item["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := byToken[fileToken]; exists {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
extraInfo, _ := item["extra_info"].(string)
|
||||
mimeType, _ := item["mime_type"].(string)
|
||||
byToken[fileToken] = baseAttachmentDownloadItem{
|
||||
RecordID: recordID,
|
||||
FieldID: currentFieldID,
|
||||
FileToken: fileToken,
|
||||
Name: name,
|
||||
Size: item["size"],
|
||||
ExtraInfo: extraInfo,
|
||||
MimeType: mimeType,
|
||||
RawPayload: item,
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]baseAttachmentDownloadItem, 0, len(tokens))
|
||||
if len(tokens) == 0 {
|
||||
for _, item := range byToken {
|
||||
result = append(result, item)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
||||
rightName := strings.ToLower(baseAttachmentDownloadName(result[j]))
|
||||
if leftName != rightName {
|
||||
return leftName < rightName
|
||||
}
|
||||
return result[i].FileToken < result[j].FileToken
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
for _, token := range tokens {
|
||||
item, ok := byToken[token]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseAttachmentDownloadItem, outputPath string, outputIsDir bool, overwrite bool) ([]baseAttachmentDownloadTarget, error) {
|
||||
names := downloadTargetNames(items, outputIsDir || outputPathLooksDirectory(runtime, outputPath))
|
||||
targets := make([]baseAttachmentDownloadTarget, 0, len(items))
|
||||
seen := map[string]baseAttachmentDownloadItem{}
|
||||
for _, item := range items {
|
||||
targetName := names[item.FileToken]
|
||||
targetPath := outputPath
|
||||
if targetName != "" {
|
||||
targetPath = filepath.Join(outputPath, targetName)
|
||||
}
|
||||
resolved, err := runtime.ResolveSavePath(targetPath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if previous, exists := seen[resolved]; exists {
|
||||
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||
}
|
||||
seen[resolved] = item
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
targets = append(targets, baseAttachmentDownloadTarget{
|
||||
Item: item,
|
||||
TargetPath: targetPath,
|
||||
ResolvedPath: resolved,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func downloadTargetNames(items []baseAttachmentDownloadItem, outputIsDir bool) map[string]string {
|
||||
if !outputIsDir {
|
||||
return nil
|
||||
}
|
||||
nameCounts := make(map[string]int, len(items))
|
||||
for _, item := range items {
|
||||
nameCounts[baseAttachmentDownloadName(item)]++
|
||||
}
|
||||
names := make(map[string]string, len(items))
|
||||
for _, item := range items {
|
||||
name := baseAttachmentDownloadName(item)
|
||||
if nameCounts[name] > 1 {
|
||||
name = attachmentNameWithTokenSuffix(name, item.FileToken)
|
||||
}
|
||||
names[item.FileToken] = name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func baseAttachmentDownloadName(item baseAttachmentDownloadItem) string {
|
||||
name := filepath.Base(strings.TrimSpace(item.Name))
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
name = item.FileToken
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func attachmentNameWithTokenSuffix(name, fileToken string) string {
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
if stem == "" {
|
||||
stem = name
|
||||
}
|
||||
return stem + "_" + safeAttachmentFileTokenSuffix(fileToken) + ext
|
||||
}
|
||||
|
||||
func safeAttachmentFileTokenSuffix(fileToken string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range fileToken {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('_')
|
||||
}
|
||||
suffix := strings.Trim(b.String(), "_")
|
||||
if suffix == "" {
|
||||
return "file"
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
||||
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
query := larkcore.QueryParams{}
|
||||
if item.ExtraInfo != "" {
|
||||
query.Set("extra", item.ExtraInfo)
|
||||
}
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(item.FileToken)),
|
||||
QueryParams: query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
||||
if savedPath == "" {
|
||||
savedPath = targetPath
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"record_id": item.RecordID,
|
||||
"field_id": item.FieldID,
|
||||
"file_token": item.FileToken,
|
||||
"name": item.Name,
|
||||
"size": item.Size,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"record_id": target.Item.RecordID,
|
||||
"field_id": target.Item.FieldID,
|
||||
"file_token": target.Item.FileToken,
|
||||
"name": target.Item.Name,
|
||||
"target_path": target.TargetPath,
|
||||
"resolved_path": target.ResolvedPath,
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitInternal,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "io",
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func stripMIMEParams(value string) string {
|
||||
if i := strings.IndexByte(value, ';'); i != -1 {
|
||||
value = value[:i]
|
||||
|
||||
@@ -5,6 +5,9 @@ package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -82,6 +85,42 @@ func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentImageDimensions(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
||||
img.Set(0, 0, color.RGBA{G: 255, A: 255})
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("png.Encode() error = %v", err)
|
||||
}
|
||||
fio := attachmentTestFileIO{openFile: newAttachmentTestFile(buf.Bytes())}
|
||||
|
||||
width, height, ok := detectAttachmentImageDimensions(fio, "image.png", "image/png")
|
||||
if !ok || width != 4 || height != 3 {
|
||||
t.Fatalf("detectAttachmentImageDimensions() = (%d,%d,%v), want (4,3,true)", width, height, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentImageDimensionsWarningEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
mimeType string
|
||||
want bool
|
||||
}{
|
||||
{mimeType: "image/gif", want: true},
|
||||
{mimeType: "image/jpeg", want: true},
|
||||
{mimeType: "image/png", want: true},
|
||||
{mimeType: "image/webp", want: false},
|
||||
{mimeType: "application/pdf", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mimeType, func(t *testing.T) {
|
||||
if got := attachmentImageDimensionsWarningEnabled(tt.mimeType); got != tt.want {
|
||||
t.Fatalf("attachmentImageDimensionsWarningEnabled(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordShareLinkCreate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDownloadAttachment,
|
||||
BaseRecordRemoveAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
BaseBaseGet,
|
||||
|
||||
31
shortcuts/common/drive_meta.go
Normal file
31
shortcuts/common/drive_meta.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return GetString(meta, "title"), nil
|
||||
}
|
||||
123
shortcuts/common/drive_meta_test.go
Normal file
123
shortcuts/common/drive_meta_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
var driveMetaTestSeq atomic.Int64
|
||||
|
||||
func TestFetchDriveMetaTitle(t *testing.T) {
|
||||
t.Run("returns title from batch_query response", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "My Document"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "My Document" {
|
||||
t.Errorf("title = %q, want %q", title, "My Document")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns empty string when metas is empty", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "" {
|
||||
t.Errorf("title = %q, want empty string", title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns empty string when meta has no title", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "" {
|
||||
t.Errorf("title = %q, want empty string", title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("propagates API error", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991668,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err == nil {
|
||||
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newDriveMetaTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: fmt.Sprintf("drive-meta-test-%d", driveMetaTestSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
runtime := &RuntimeContext{
|
||||
ctx: context.Background(),
|
||||
Config: cfg,
|
||||
Factory: f,
|
||||
resolvedAs: core.AsBot,
|
||||
}
|
||||
return runtime, reg
|
||||
}
|
||||
@@ -33,6 +33,26 @@ func GetFloat(m map[string]interface{}, keys ...string) float64 {
|
||||
return f
|
||||
}
|
||||
|
||||
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.
|
||||
func GetInt(m map[string]interface{}, keys ...string) int {
|
||||
if len(keys) == 0 {
|
||||
return 0
|
||||
}
|
||||
v := navigate(m, keys[:len(keys)-1])
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch n := v[keys[len(keys)-1]].(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetBool safely extracts a bool.
|
||||
func GetBool(m map[string]interface{}, keys ...string) bool {
|
||||
if len(keys) == 0 {
|
||||
|
||||
@@ -64,6 +64,32 @@ func TestGetFloat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInt(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"count": 42,
|
||||
"json_count": 7.0,
|
||||
"data": map[string]interface{}{
|
||||
"score": int64(99),
|
||||
},
|
||||
}
|
||||
|
||||
if got := GetInt(m, "count"); got != 42 {
|
||||
t.Errorf("GetInt(count) = %d, want 42", got)
|
||||
}
|
||||
if got := GetInt(m, "json_count"); got != 7 {
|
||||
t.Errorf("GetInt(json_count) = %d, want 7", got)
|
||||
}
|
||||
if got := GetInt(m, "data", "score"); got != 99 {
|
||||
t.Errorf("GetInt(data.score) = %d, want 99", got)
|
||||
}
|
||||
if got := GetInt(m, "missing"); got != 0 {
|
||||
t.Errorf("GetInt(missing) = %d, want 0", got)
|
||||
}
|
||||
if got := GetInt(m); got != 0 {
|
||||
t.Errorf("GetInt() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBool(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"active": true,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -55,3 +56,79 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceRef holds the parsed type and token from a Lark resource URL.
|
||||
type ResourceRef struct {
|
||||
Type string // e.g. "docx", "bitable", "wiki", "sheet", etc.
|
||||
Token string // the token extracted from the URL path
|
||||
}
|
||||
|
||||
// urlPathToType maps URL path prefixes to resource types.
|
||||
// Longer prefixes must come first to avoid false matches
|
||||
// (e.g. "/drive/folder/" before a hypothetical "/drive/").
|
||||
// Aliases (e.g. "/bitable/" → "bitable") must come after the
|
||||
// canonical prefix to keep the list deterministic.
|
||||
var urlPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnote/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
}
|
||||
|
||||
// ParseResourceURL parses a Lark/Feishu URL and extracts the resource type
|
||||
// and token from the URL path. It is the inverse of BuildResourceURL.
|
||||
//
|
||||
// Supported path patterns:
|
||||
//
|
||||
// /docx/TOKEN -> {Type: "docx", Token: TOKEN}
|
||||
// /doc/TOKEN -> {Type: "doc", Token: TOKEN}
|
||||
// /sheets/TOKEN -> {Type: "sheet", Token: TOKEN}
|
||||
// /base/TOKEN -> {Type: "bitable", Token: TOKEN}
|
||||
// /wiki/TOKEN -> {Type: "wiki", Token: TOKEN}
|
||||
// /file/TOKEN -> {Type: "file", Token: TOKEN}
|
||||
// /drive/folder/TOKEN -> {Type: "folder", Token: TOKEN}
|
||||
// /mindnote/TOKEN -> {Type: "mindnote", Token: TOKEN}
|
||||
// /slides/TOKEN -> {Type: "slides", Token: TOKEN}
|
||||
//
|
||||
// Returns (ResourceRef{}, false) when the URL does not match any known pattern.
|
||||
func ParseResourceURL(rawURL string) (ResourceRef, bool) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
path := u.Path
|
||||
|
||||
for _, mapping := range urlPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
// Trim trailing slashes and stop at the next path segment boundary.
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
return ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,102 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestParseResourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantType string
|
||||
wantToken string
|
||||
wantOK bool
|
||||
}{
|
||||
// All 9 supported types
|
||||
{"docx", "https://xxx.feishu.cn/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
{"doc", "https://xxx.feishu.cn/doc/doccnABC", "doc", "doccnABC", true},
|
||||
{"sheet", "https://xxx.feishu.cn/sheets/shtcnABC", "sheet", "shtcnABC", true},
|
||||
{"bitable via /base/", "https://xxx.feishu.cn/base/bascnABC", "bitable", "bascnABC", true},
|
||||
{"bitable via /bitable/", "https://xxx.feishu.cn/bitable/bascnABC", "bitable", "bascnABC", true},
|
||||
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
|
||||
{"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true},
|
||||
{"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true},
|
||||
{"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true},
|
||||
{"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true},
|
||||
|
||||
// Lark domain
|
||||
{"lark docx", "https://xxx.larksuite.com/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
{"lark wiki", "https://xxx.larksuite.com/wiki/wikcnABC", "wiki", "wikcnABC", true},
|
||||
|
||||
// With query parameters
|
||||
{"with query", "https://xxx.feishu.cn/docx/doxcnABC?from=wiki", "docx", "doxcnABC", true},
|
||||
{"with fragment", "https://xxx.feishu.cn/docx/doxcnABC#section", "docx", "doxcnABC", true},
|
||||
|
||||
// With trailing slash
|
||||
{"trailing slash", "https://xxx.feishu.cn/docx/doxcnABC/", "docx", "doxcnABC", true},
|
||||
|
||||
// With extra path segments after token
|
||||
{"extra path", "https://xxx.feishu.cn/docx/doxcnABC/edit", "docx", "doxcnABC", true},
|
||||
|
||||
// Non-Lark host with Lark-like path (host validation is the caller's responsibility)
|
||||
{"non-lark host with lark path", "https://google.com/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
|
||||
// Negative cases
|
||||
{"unrecognized path", "https://xxx.feishu.cn/calendar/calABC", "", "", false},
|
||||
{"non-lark host unrecognized path", "https://example.com/page", "", "", false},
|
||||
{"empty input", "", "", "", false},
|
||||
{"bare token", "doxcnABC", "", "", false},
|
||||
{"invalid url parse", "://not-a-valid-url", "", "", false},
|
||||
{"matching prefix but empty token", "https://xxx.feishu.cn/docx/", "", "", false},
|
||||
{"matching prefix but whitespace-only token", "https://xxx.feishu.cn/docx/ ", "", "", false},
|
||||
{"whitespace-only input", " ", "", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ref, ok := ParseResourceURL(tt.rawURL)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseResourceURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK)
|
||||
}
|
||||
if ok {
|
||||
if ref.Type != tt.wantType {
|
||||
t.Errorf("ParseResourceURL(%q) Type = %q, want %q", tt.rawURL, ref.Type, tt.wantType)
|
||||
}
|
||||
if ref.Token != tt.wantToken {
|
||||
t.Errorf("ParseResourceURL(%q) Token = %q, want %q", tt.rawURL, ref.Token, tt.wantToken)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseResourceURL_RoundTrip verifies that ParseResourceURL is the inverse
|
||||
// of BuildResourceURL for all supported types.
|
||||
func TestParseResourceURL_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
types := []string{"docx", "doc", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"}
|
||||
token := "testTOKEN123"
|
||||
|
||||
for _, kind := range types {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
built := BuildResourceURL(core.BrandFeishu, kind, token)
|
||||
if built == "" {
|
||||
t.Fatalf("BuildResourceURL returned empty for kind %q", kind)
|
||||
}
|
||||
ref, ok := ParseResourceURL(built)
|
||||
if !ok {
|
||||
t.Fatalf("ParseResourceURL(%q) returned ok=false", built)
|
||||
}
|
||||
if ref.Type != kind {
|
||||
t.Errorf("round-trip type mismatch: got %q, want %q", ref.Type, kind)
|
||||
}
|
||||
if ref.Token != token {
|
||||
t.Errorf("round-trip token mismatch: got %q, want %q", ref.Token, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -103,13 +103,15 @@ func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
||||
// payload is under "bot", not "data" as the newer Lark API convention.
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"data"`
|
||||
} `json:"bot"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestFetchBotInfo_Success(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_abc123",
|
||||
"app_name": "TestBot",
|
||||
},
|
||||
@@ -86,7 +86,7 @@ func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_header",
|
||||
"app_name": "HeaderBot",
|
||||
},
|
||||
@@ -119,7 +119,7 @@ func TestFetchBotInfo_OnceSemantics(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_once",
|
||||
"app_name": "OnceBot",
|
||||
},
|
||||
@@ -183,7 +183,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "",
|
||||
"app_name": "EmptyBot",
|
||||
},
|
||||
|
||||
@@ -118,7 +118,7 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
return common.FlagErrorf(selectionRequiredMessageV1(mode))
|
||||
}
|
||||
if err := validateSelectionByTitleV1(selTitle); err != nil {
|
||||
return err
|
||||
@@ -127,6 +127,14 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectionRequiredMessageV1(mode string) string {
|
||||
msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
if mode == "replace_all" {
|
||||
msg += ". If you intended to replace the entire document body, use --mode overwrite instead."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func validateSelectionByTitleV1(title string) error {
|
||||
if title == "" {
|
||||
return nil
|
||||
|
||||
@@ -4,6 +4,7 @@ package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,33 @@ func TestValidCommandsV2(t *testing.T) {
|
||||
|
||||
// ── V1 tests ──
|
||||
|
||||
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_all")
|
||||
for _, needle := range []string{
|
||||
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
|
||||
"replace the entire document body",
|
||||
"--mode overwrite",
|
||||
} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Fatalf("message missing %q: %s", needle, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_range")
|
||||
if strings.Contains(msg, "--mode overwrite") {
|
||||
t.Fatalf("replace_range message should not suggest overwrite: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") {
|
||||
t.Fatalf("unexpected message: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
t.Run("blank whiteboard tags", func(t *testing.T) {
|
||||
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
"docs:document:export",
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
// 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").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
@@ -101,28 +102,38 @@ var DriveExport = common.Shortcut{
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk.
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/docs/v1/content",
|
||||
map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
},
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.DoAPIJSONWithLogID(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
@@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len([]byte(common.GetString(data, "content"))),
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -228,34 +228,6 @@ func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExp
|
||||
return status
|
||||
}
|
||||
|
||||
// fetchDriveMetaTitle looks up the document title so exported files can use a
|
||||
// human-readable default name when possible.
|
||||
func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
// saveContentToOutputDir validates the target path, enforces overwrite policy,
|
||||
// and writes the payload atomically via FileIO.Save.
|
||||
func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
|
||||
|
||||
@@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -132,16 +143,19 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
wantURL: "/open-apis/docs/v1/content",
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
wantFileName: `"file_name": "notes.md"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
@@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document object, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
|
||||
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document.content, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
|
||||
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
183
shortcuts/drive/drive_inspect.go
Normal file
183
shortcuts/drive/drive_inspect.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var DriveInspect = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+inspect",
|
||||
Description: "Inspect a Lark document URL to get its type, title, and canonical token (with wiki unwrapping)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{
|
||||
Name: "url",
|
||||
Desc: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "type",
|
||||
Desc: "document type (required when --url is a bare token; auto-detected for URLs)",
|
||||
Enum: []string{"doc", "docx", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"},
|
||||
},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
if raw == "" {
|
||||
return output.ErrValidation("--url cannot be empty")
|
||||
}
|
||||
|
||||
_, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Not a recognized URL pattern.
|
||||
if strings.Contains(raw, "://") {
|
||||
return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw)
|
||||
}
|
||||
// Bare token: --type is required.
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
|
||||
if ref.Type == "wiki" {
|
||||
dry.Desc("2-step: inspect wiki node, then batch query metadata")
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Inspect wiki node to get underlying document").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[2] Batch query document metadata (title)").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{"doc_token": "<obj_token from step 1>", "doc_type": "<obj_type from step 1>"},
|
||||
},
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
dry.Desc("1-step: batch query document metadata")
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{"doc_token": ref.Token, "doc_type": ref.Type},
|
||||
},
|
||||
})
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
|
||||
// Step 1: Parse URL to extract {type, token}.
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Bare token: use --type.
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
}
|
||||
|
||||
inputURL := raw
|
||||
docType := ref.Type
|
||||
docToken := ref.Token
|
||||
|
||||
var wikiNode map[string]interface{}
|
||||
|
||||
// Step 2: If type is "wiki", unwrap via get_node API.
|
||||
if docType == "wiki" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docToken},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
spaceID := common.GetString(node, "space_id")
|
||||
nodeToken := common.GetString(node, "node_token")
|
||||
|
||||
if objType == "" || objToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
|
||||
wikiNode = map[string]interface{}{
|
||||
"space_id": spaceID,
|
||||
"node_token": nodeToken,
|
||||
"obj_token": objToken,
|
||||
"obj_type": objType,
|
||||
}
|
||||
|
||||
docType = objType
|
||||
docToken = objToken
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki unwrapped to %s: %s\n", docType, common.MaskToken(docToken))
|
||||
}
|
||||
|
||||
// Step 3: Call batch_query to verify and get title.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Build the resolved URL.
|
||||
resolvedURL := common.BuildResourceURL(runtime.Config.Brand, docType, docToken)
|
||||
|
||||
// Step 5: Build output.
|
||||
result := map[string]interface{}{
|
||||
"input_url": inputURL,
|
||||
"type": docType,
|
||||
"title": title,
|
||||
"token": docToken,
|
||||
"url": resolvedURL,
|
||||
}
|
||||
if wikiNode != nil {
|
||||
result["wiki_node"] = wikiNode
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", docType)
|
||||
if title != "" {
|
||||
fmt.Fprintf(w, "Title: %s\n", title)
|
||||
}
|
||||
fmt.Fprintf(w, "Token: %s\n", docToken)
|
||||
if resolvedURL != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", resolvedURL)
|
||||
}
|
||||
if wikiNode != nil {
|
||||
fmt.Fprintf(w, "Wiki: space_id=%s, node_token=%s\n", wikiNode["space_id"], wikiNode["node_token"])
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
466
shortcuts/drive/drive_inspect_test.go
Normal file
466
shortcuts/drive/drive_inspect_test.go
Normal file
@@ -0,0 +1,466 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestDriveInspectValidate_EmptyURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --url, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_UnsupportedURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://google.com/some/page")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_NonLarkHostWithLarkPath(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://google.com/docx/doxcnLooksValid")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for non-Lark host with Lark-like path (host validation removed), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithoutType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token without --type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidWikiURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API step, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Errorf("API URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[0].URL)
|
||||
}
|
||||
// Verify body contains request_docs with the correct token and type.
|
||||
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
|
||||
if !ok || len(reqDocs) != 1 {
|
||||
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
|
||||
}
|
||||
doc, _ := reqDocs[0].(map[string]interface{})
|
||||
if doc["doc_token"] != "doxcnABC" {
|
||||
t.Errorf("doc_token = %v, want doxcnABC", doc["doc_token"])
|
||||
}
|
||||
if doc["doc_type"] != "docx" {
|
||||
t.Errorf("doc_type = %v, want docx", doc["doc_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_WikiURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API steps, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Errorf("step 1 URL = %q, want /open-apis/wiki/v2/spaces/get_node", got.API[0].URL)
|
||||
}
|
||||
// Verify step 1 params contain the wiki token.
|
||||
if got.API[0].Params["token"] != "wikcnABC" {
|
||||
t.Errorf("step 1 params.token = %v, want wikcnABC", got.API[0].Params["token"])
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Errorf("step 2 URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[1].URL)
|
||||
}
|
||||
// Verify step 2 body contains request_docs placeholder.
|
||||
if got.API[1].Body["request_docs"] == nil {
|
||||
t.Error("step 2 body should contain request_docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API step, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestDriveInspectExecute_DocxURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnABC" {
|
||||
t.Errorf("token = %v, want doxcnABC", data["token"])
|
||||
}
|
||||
if data["title"] != "Test Doc" {
|
||||
t.Errorf("title = %v, want Test Doc", data["title"])
|
||||
}
|
||||
if _, ok := data["wiki_node"]; ok {
|
||||
t.Error("wiki_node should not be present for non-wiki URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_WikiURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
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": "doxcnUnwrapped",
|
||||
"space_id": "space123",
|
||||
"node_token": "wikcnNodeToken",
|
||||
"title": "Wiki Doc",
|
||||
"node_type": "origin",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx (unwrapped from wiki)", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnUnwrapped" {
|
||||
t.Errorf("token = %v, want doxcnUnwrapped", data["token"])
|
||||
}
|
||||
if data["title"] != "Wiki Doc" {
|
||||
t.Errorf("title = %v, want Wiki Doc", data["title"])
|
||||
}
|
||||
wikiNode, ok := data["wiki_node"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("wiki_node should be present for wiki URL")
|
||||
}
|
||||
if wikiNode["space_id"] != "space123" {
|
||||
t.Errorf("wiki_node.space_id = %v, want space123", wikiNode["space_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
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": "",
|
||||
"obj_token": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incomplete wiki node data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnBare", "doc_type": "docx", "title": "Bare Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "doxcnBare",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnBare" {
|
||||
t.Errorf("token = %v, want doxcnBare", data["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991668,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for batch_query failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Pretty format outputs to stdout as text, not JSON envelope.
|
||||
// Just verify it didn't error.
|
||||
_ = stdout
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// driveStatusScopedTokenResolver returns a token with caller-controlled scopes
|
||||
@@ -804,3 +806,59 @@ func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
|
||||
t.Fatalf("error must reference --folder-token, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) {
|
||||
missingRoot := filepath.Join(t.TempDir(), "does-not-exist")
|
||||
|
||||
_, err := walkLocalForStatus(missingRoot, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("expected walkLocalForStatus() to fail for missing root")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "io" {
|
||||
t.Fatalf("expected io error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "walk") {
|
||||
t.Fatalf("expected walk-related error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashLocalForStatusWrapsOpenError(t *testing.T) {
|
||||
config := driveTestConfig()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, config)
|
||||
runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, config)
|
||||
runtime.Factory = f
|
||||
|
||||
_, err := hashLocalForStatus(runtime, "missing.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected hashLocalForStatus() to fail for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing.txt") {
|
||||
t.Fatalf("expected error to mention the missing file, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashRemoteForStatusReturnsNetworkErrorWhenDownloadFails(t *testing.T) {
|
||||
config := driveTestConfig()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, config)
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{Use: "drive"}, config)
|
||||
runtime.Factory = f
|
||||
|
||||
_, err := hashRemoteForStatus(context.Background(), runtime, "tok_missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected hashRemoteForStatus() to fail when the download request has no stub")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("expected network detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "download") {
|
||||
t.Fatalf("expected download-related error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
650
shortcuts/drive/drive_sync.go
Normal file
650
shortcuts/drive/drive_sync.go
Normal file
@@ -0,0 +1,650 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveSyncOnConflictLocalWins = "local-wins"
|
||||
driveSyncOnConflictRemoteWins = "remote-wins"
|
||||
driveSyncOnConflictKeepBoth = "keep-both"
|
||||
driveSyncOnConflictAsk = "ask"
|
||||
)
|
||||
|
||||
type driveSyncItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Direction string `json:"direction,omitempty"` // "pull" or "push"
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DriveSync performs a two-way sync between a local directory and a Drive
|
||||
// folder. It computes a diff (like +status), then:
|
||||
// - new_remote → pull (download to local)
|
||||
// - new_local → push (upload to Drive)
|
||||
// - modified → resolve by --on-conflict strategy:
|
||||
// local-wins: push local over remote;
|
||||
// remote-wins: pull remote over local;
|
||||
// keep-both: rename the local file with a hash suffix and pull the remote;
|
||||
// ask: prompt the user per conflict.
|
||||
var DriveSync = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+sync",
|
||||
Description: "Two-way sync between a local directory and a Drive folder",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
ConditionalScopes: []string{
|
||||
"drive:file:download",
|
||||
"drive:file:upload",
|
||||
"space:folder:create",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "Drive folder token", Required: true},
|
||||
{Name: "on-conflict", Desc: "conflict resolution when both sides modified a file", Default: driveSyncOnConflictRemoteWins, Enum: []string{driveSyncOnConflictLocalWins, driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth, driveSyncOnConflictAsk}},
|
||||
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
||||
{Name: "quick", Type: "bool", Desc: "use best-effort modified_time comparison instead of SHA-256 hash; mismatched timestamps can still trigger real sync writes"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Two-way sync: new remote files are pulled, new local files are pushed, and conflicts (both sides modified) are resolved by --on-conflict.",
|
||||
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
|
||||
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
|
||||
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
|
||||
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Compute diff between --local-dir and --folder-token, then pull new/modified-remote files, push new/modified-local files, and resolve conflicts by --on-conflict strategy.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
onConflict := strings.TrimSpace(runtime.Str("on-conflict"))
|
||||
if onConflict == "" {
|
||||
onConflict = driveSyncOnConflictRemoteWins
|
||||
}
|
||||
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
|
||||
if duplicateRemote == "" {
|
||||
duplicateRemote = driveDuplicateRemoteFail
|
||||
}
|
||||
quick := runtime.Bool("quick")
|
||||
if !quick {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
|
||||
}
|
||||
|
||||
// --- Phase 1: Compute diff (same logic as +status) ---
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
|
||||
return duplicateRemotePathError(duplicates)
|
||||
}
|
||||
|
||||
// A local regular file at the same rel_path as a remote
|
||||
// folder/docx/shortcut is a type conflict: +sync would
|
||||
// classify it as new_local and attempt to upload, which either
|
||||
// fails at the API or leaves the remote in a broken state
|
||||
// (same rel_path with mixed types). Detect early and hard-fail.
|
||||
// Symmetrically, a local directory at the same rel_path as a
|
||||
// remote file/docx/shortcut would attempt create_folder and
|
||||
// produce the same broken mixed-type state.
|
||||
var typeConflicts []string
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
continue
|
||||
}
|
||||
if _, hasLocal := localFiles[entry.RelPath]; hasLocal {
|
||||
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local file vs remote %s", entry.RelPath, entry.Type))
|
||||
}
|
||||
}
|
||||
// Check local directories vs remote non-folder entries.
|
||||
// localDirs is not available yet (walked later), so check
|
||||
// the filesystem directly for the subset of remote paths
|
||||
// that are non-folder.
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFolder {
|
||||
continue
|
||||
}
|
||||
dirPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
|
||||
if info, err := os.Stat(dirPath); err == nil && info.IsDir() { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local directory vs remote %s", entry.RelPath, entry.Type))
|
||||
}
|
||||
}
|
||||
if len(typeConflicts) > 0 {
|
||||
return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
|
||||
}
|
||||
|
||||
// Build the exact remote-file views that later execution will use so the
|
||||
// diff phase classifies files against the same duplicate-resolution choice.
|
||||
pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
|
||||
remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles)
|
||||
|
||||
paths := mergeStatusPaths(localFiles, remoteFiles)
|
||||
|
||||
var newLocal, newRemote, modified []driveStatusEntry
|
||||
var unchanged []driveStatusEntry
|
||||
for _, relPath := range paths {
|
||||
localFile, hasLocal := localFiles[relPath]
|
||||
remoteFile, hasRemote := remoteFiles[relPath]
|
||||
switch {
|
||||
case hasLocal && !hasRemote:
|
||||
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
||||
case !hasLocal && hasRemote:
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
|
||||
default:
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
|
||||
if quick {
|
||||
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
modified = append(modified, entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if localHash == remoteHash {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
modified = append(modified, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detection := driveStatusDetectionExact
|
||||
if quick {
|
||||
detection = driveStatusDetectionQuick
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Diff: %d new_local, %d new_remote, %d modified, %d unchanged (detection=%s)\n",
|
||||
len(newLocal), len(newRemote), len(modified), len(unchanged), detection)
|
||||
|
||||
conflictResolutions := make(map[string]string, len(modified))
|
||||
if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil {
|
||||
return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist")
|
||||
}
|
||||
for _, entry := range modified {
|
||||
resolved := onConflict
|
||||
if resolved == driveSyncOnConflictAsk {
|
||||
resolved, err = driveSyncAskConflict(entry.RelPath, runtime)
|
||||
if err != nil {
|
||||
payload := map[string]interface{}{
|
||||
"detection": detection,
|
||||
"diff": map[string]interface{}{
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
"unchanged": emptyIfNil(unchanged),
|
||||
},
|
||||
"summary": map[string]interface{}{
|
||||
"pulled": 0,
|
||||
"pushed": 0,
|
||||
"skipped": 0,
|
||||
"failed": 1,
|
||||
},
|
||||
"items": []driveSyncItem{{
|
||||
RelPath: entry.RelPath,
|
||||
FileToken: entry.FileToken,
|
||||
Action: "failed",
|
||||
Direction: "conflict",
|
||||
Error: err.Error(),
|
||||
}},
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_failure",
|
||||
Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err),
|
||||
Detail: payload,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
conflictResolutions[entry.RelPath] = resolved
|
||||
}
|
||||
|
||||
// --- Phase 2: Execute sync operations ---
|
||||
var pulled, pushed, skipped, failed int
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
|
||||
if len(plannedUploads) > 0 {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
folderCache := map[string]string{"": folderToken}
|
||||
for relDir, entry := range remoteFolders {
|
||||
folderCache[relDir] = entry.FileToken
|
||||
}
|
||||
|
||||
// Walk local filesystem early so we can include empty directories
|
||||
// in the scope preflight (they also need space:folder:create).
|
||||
pushLocalFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
|
||||
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||
pushed++
|
||||
}
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
if !ok {
|
||||
// Non-file type (doc, shortcut, etc.) — skip.
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
|
||||
pulled++
|
||||
}
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
if !ok {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
|
||||
pushed++
|
||||
}
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
localFile, hasLocal := pushLocalFiles[entry.RelPath]
|
||||
if !hasLocal {
|
||||
// Should not happen — modified means both sides exist.
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "local file disappeared during sync"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
resolved := conflictResolutions[entry.RelPath]
|
||||
if resolved == "" {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "user skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
switch resolved {
|
||||
case driveSyncOnConflictRemoteWins:
|
||||
// Pull remote over local.
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
if !ok {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: "remote file not found in pull views"})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
|
||||
pulled++
|
||||
|
||||
case driveSyncOnConflictLocalWins:
|
||||
// Push local over remote.
|
||||
existingToken := remoteFile.FileToken
|
||||
if existingToken == "" {
|
||||
if chosen, ok := remoteEntriesForPush[entry.RelPath]; ok {
|
||||
existingToken = chosen.FileToken
|
||||
}
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||
if upErr != nil {
|
||||
// Token contract on overwrite failure (same as +push):
|
||||
// a partial-success response can return a non-empty
|
||||
// file_token alongside an error. Prefer the freshly
|
||||
// returned token when one was produced, fall back to
|
||||
// existingToken otherwise.
|
||||
failedToken := token
|
||||
if failedToken == "" {
|
||||
failedToken = existingToken
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
|
||||
pushed++
|
||||
|
||||
case driveSyncOnConflictKeepBoth:
|
||||
// Rename the local file with a hash suffix, then pull the remote.
|
||||
// Use the remote file token to generate a stable suffix (same
|
||||
// pattern as +pull --on-duplicate-remote=rename).
|
||||
occupied := occupiedRemotePaths(entries)
|
||||
// Add current local paths to occupied set so the renamed
|
||||
// local file doesn't collide with an existing file or directory.
|
||||
for p := range pushLocalFiles {
|
||||
occupied[p] = struct{}{}
|
||||
}
|
||||
for _, relDir := range localDirs {
|
||||
occupied[relDir] = struct{}{}
|
||||
}
|
||||
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
|
||||
if err != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
// Rename the local file.
|
||||
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
|
||||
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
|
||||
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
occupied[suffixedRel] = struct{}{}
|
||||
// Now pull the remote version to the original path.
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
if !ok {
|
||||
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
|
||||
errMsg := "remote file not found in pull views after rename"
|
||||
if rollbackErr != nil {
|
||||
errMsg += "; rollback failed: " + rollbackErr.Error()
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
|
||||
errMsg := err.Error()
|
||||
if rollbackErr != nil {
|
||||
errMsg += "; rollback failed: " + rollbackErr.Error()
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
|
||||
pulled++
|
||||
|
||||
default:
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: fmt.Sprintf("unknown conflict strategy: %s", resolved)})
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"detection": detection,
|
||||
"diff": map[string]interface{}{
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
"unchanged": emptyIfNil(unchanged),
|
||||
},
|
||||
"summary": map[string]interface{}{
|
||||
"pulled": pulled,
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
msg := fmt.Sprintf("%d item(s) failed during +sync", failed)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_failure",
|
||||
Message: msg,
|
||||
Detail: payload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[string]driveStatusRemoteFile {
|
||||
remoteFiles := make(map[string]driveStatusRemoteFile, len(pullRemoteFiles))
|
||||
for relPath, target := range pullRemoteFiles {
|
||||
fileToken := target.ItemFileToken
|
||||
if fileToken == "" {
|
||||
fileToken = target.DownloadToken
|
||||
}
|
||||
remoteFiles[relPath] = driveStatusRemoteFile{FileToken: fileToken, ModifiedTime: target.ModifiedTime}
|
||||
}
|
||||
return remoteFiles
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath)
|
||||
if runtime.IO().In == nil {
|
||||
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath)
|
||||
}
|
||||
reader, ok := runtime.IO().In.(*bufio.Reader)
|
||||
if !ok {
|
||||
reader = bufio.NewReader(runtime.IO().In)
|
||||
runtime.IO().In = reader
|
||||
}
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err)
|
||||
}
|
||||
answer := strings.TrimSpace(strings.ToLower(line))
|
||||
if answer == "" {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath)
|
||||
}
|
||||
return driveSyncOnConflictRemoteWins, nil
|
||||
}
|
||||
switch answer {
|
||||
case "l", "local", "local-wins":
|
||||
return driveSyncOnConflictLocalWins, nil
|
||||
case "k", "keep", "keep-both":
|
||||
return driveSyncOnConflictKeepBoth, nil
|
||||
case "s", "skip":
|
||||
return "", nil
|
||||
case "r", "remote", "remote-wins":
|
||||
return driveSyncOnConflictRemoteWins, nil
|
||||
default:
|
||||
return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
|
||||
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
|
||||
if len(newRemote) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, entry := range modified {
|
||||
switch conflictResolutions[entry.RelPath] {
|
||||
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
|
||||
planned := make([]string, 0, len(newLocal)+len(modified))
|
||||
for _, entry := range newLocal {
|
||||
planned = append(planned, entry.RelPath)
|
||||
}
|
||||
for _, entry := range modified {
|
||||
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
|
||||
planned = append(planned, entry.RelPath)
|
||||
}
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
|
||||
for _, relPath := range uploadPaths {
|
||||
parentRel := drivePushParentRel(relPath)
|
||||
if parentRel == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := folderCache[parentRel]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Empty local directories also need create_folder if not already on Drive.
|
||||
for _, relDir := range localDirs {
|
||||
if _, ok := folderCache[relDir]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
|
||||
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
if info.IsDir() {
|
||||
return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath)
|
||||
}
|
||||
if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err)
|
||||
}
|
||||
if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
3097
shortcuts/drive/drive_sync_test.go
Normal file
3097
shortcuts/drive/drive_sync_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import (
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, or wiki delete-space operations",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
|
||||
Risk: "read",
|
||||
// This shortcut multiplexes multiple backend APIs with different scope
|
||||
// requirements, so scenario-specific prechecks are handled in Validate.
|
||||
@@ -28,8 +28,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, or wiki_delete_space tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, or wiki_delete_space", Required: true},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -40,9 +40,10 @@ var DriveTaskResult = common.Shortcut{
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
"wiki_delete_space": true,
|
||||
"wiki_delete_node": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space", scenario)
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
@@ -54,7 +55,7 @@ var DriveTaskResult = common.Shortcut{
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check", "wiki_move", "wiki_delete_space":
|
||||
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for %s scenario", scenario)
|
||||
}
|
||||
@@ -108,6 +109,11 @@ var DriveTaskResult = common.Shortcut{
|
||||
Desc("[1] Query wiki delete-space task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "delete_space"})
|
||||
case "wiki_delete_node":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki delete-node task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "delete_node"})
|
||||
}
|
||||
|
||||
return dry
|
||||
@@ -136,6 +142,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
result, err = queryWikiMoveTask(runtime, taskID)
|
||||
case "wiki_delete_space":
|
||||
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
|
||||
case "wiki_delete_node":
|
||||
result, err = queryWikiDeleteNodeTask(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -236,7 +244,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
|
||||
switch scenario {
|
||||
case "import", "export", "task_check":
|
||||
required = []string{"drive:drive.metadata:readonly"}
|
||||
case "wiki_move", "wiki_delete_space":
|
||||
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
required = []string{"wiki:space:read"}
|
||||
}
|
||||
|
||||
@@ -540,3 +548,64 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
|
||||
"status_msg": label,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryWikiDeleteNodeTask returns the normalized status of an async wiki
|
||||
// delete-node task. For historical reasons the gateway stashes delete-node
|
||||
// status under the generic `simple_task_result` key (NOT `delete_node_result`),
|
||||
// and that object only carries `status` — there is no `status_msg`, so the
|
||||
// label falls back to the status code. Mirrors queryWikiDeleteSpaceTask;
|
||||
// intentionally duplicated here (rather than importing the wiki package) to
|
||||
// keep drive from depending on shortcuts/wiki.
|
||||
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "delete_node"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := common.GetMap(data, "task")
|
||||
if task == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
resolvedTaskID := common.GetString(task, "task_id")
|
||||
if resolvedTaskID == "" {
|
||||
resolvedTaskID = taskID
|
||||
}
|
||||
|
||||
result := common.GetMap(task, "simple_task_result")
|
||||
var status string
|
||||
if result != nil {
|
||||
status = common.GetString(result, "status")
|
||||
}
|
||||
|
||||
// Keep in sync with wiki.parseWikiAsyncTaskStatus / wikiAsyncTaskStatus
|
||||
// classification (intentionally duplicated to avoid a drive→wiki import —
|
||||
// see the doc comment above). If the success/failed/processing rules change
|
||||
// there, mirror the change here.
|
||||
lowered := strings.ToLower(strings.TrimSpace(status))
|
||||
ready := lowered == "success"
|
||||
failed := lowered == "failure" || lowered == "failed"
|
||||
|
||||
resolvedStatus := strings.TrimSpace(status)
|
||||
if resolvedStatus == "" {
|
||||
resolvedStatus = "processing"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "wiki_delete_node",
|
||||
"task_id": resolvedTaskID,
|
||||
"ready": ready,
|
||||
"failed": failed,
|
||||
"status": resolvedStatus,
|
||||
"status_msg": resolvedStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -417,10 +417,10 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
|
||||
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// wiki_move and wiki_delete_space both read wiki task status, so both must
|
||||
// require wiki:space:read. A single table keeps this invariant explicit
|
||||
// without duplicating near-identical test functions per scenario.
|
||||
for _, scenario := range []string{"wiki_move", "wiki_delete_space"} {
|
||||
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
|
||||
// status, so all must require wiki:space:read. A single table keeps this
|
||||
// invariant explicit without duplicating near-identical test functions.
|
||||
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
|
||||
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
|
||||
@@ -518,6 +518,105 @@ func TestDriveTaskResultWikiDeleteSpaceSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunWikiDeleteNodeIncludesTaskTypeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "wiki_delete_node"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("task-id", "task_del_node_1"); err != nil {
|
||||
t.Fatalf("set --task-id: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Params["task_type"] != "delete_node" {
|
||||
t.Fatalf("wiki delete-node params = %#v, want task_type=delete_node", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiDeleteNodeSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_del_node_1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// Gateway returns delete-node status under the generic
|
||||
// simple_task_result key (NOT delete_node_result), and it
|
||||
// carries only `status` (no status_msg).
|
||||
"simple_task_result": map[string]interface{}{
|
||||
"status": "success",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_delete_node",
|
||||
"--task-id", "task_del_node_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["scenario"] != "wiki_delete_node" || data["task_id"] != "task_del_node_1" {
|
||||
t.Fatalf("unexpected wiki_delete_node envelope: %#v", data)
|
||||
}
|
||||
if data["ready"] != true || data["failed"] != false || data["status"] != "success" {
|
||||
t.Fatalf("unexpected readiness fields: %#v", data)
|
||||
}
|
||||
// simple_task_result has no status_msg; label must fall back to status.
|
||||
if data["status_msg"] != "success" {
|
||||
t.Fatalf("status_msg = %#v, want fallback to status", data["status_msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultRejectsUnknownScenarioListsWikiDeleteNode(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "bogus",
|
||||
"--task-id", "task_x",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki_delete_node") {
|
||||
t.Fatalf("expected unsupported-scenario error listing wiki_delete_node, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -25,8 +25,10 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveStatus,
|
||||
DrivePush,
|
||||
DrivePull,
|
||||
DriveSync,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSearch,
|
||||
DriveInspect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+status",
|
||||
"+push",
|
||||
"+pull",
|
||||
"+sync",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+search",
|
||||
"+inspect",
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
|
||||
@@ -166,6 +166,7 @@ type DraftProjection struct {
|
||||
LargeAttachmentsSummary []LargeAttachmentSummary `json:"large_attachments_summary,omitempty"`
|
||||
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
|
||||
@@ -140,9 +140,53 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
|
||||
|
||||
proj.LargeAttachmentsSummary = projectLargeAttachments(snapshot.Headers, htmlBody)
|
||||
|
||||
proj.Priority = parsePriorityFromHeaders(snapshot.Headers)
|
||||
|
||||
return proj
|
||||
}
|
||||
|
||||
// parsePriorityFromHeaders derives the read-side priority projection from
|
||||
// EML headers. It mirrors the write-side helper helpers.go:parsePriority
|
||||
// (which translates --set-priority high|normal|low into set_header /
|
||||
// remove_header X-Cli-Priority ops). Lookup order is case-insensitive
|
||||
// via headerValue:
|
||||
// 1. X-Cli-Priority (CLI/OAPI-specific header recognised by
|
||||
// mail-data-access headersToPbBodyExtra)
|
||||
// 2. X-Priority (RFC standard, fallback for IMAP-回灌 historical drafts)
|
||||
//
|
||||
// When neither header is present (including after the write-side translates
|
||||
// --set-priority normal into remove_header X-Cli-Priority), this returns
|
||||
// "normal" — absence of a priority header is the standard email convention
|
||||
// for normal priority. Agents cannot distinguish "explicitly normal" from
|
||||
// "never set" — known limitation.
|
||||
func parsePriorityFromHeaders(headers []Header) string {
|
||||
if v := headerValue(headers, "X-Cli-Priority"); v != "" {
|
||||
return mapPriorityValue(v)
|
||||
}
|
||||
if v := headerValue(headers, "X-Priority"); v != "" {
|
||||
return mapPriorityValue(v)
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// mapPriorityValue normalises a raw priority header value to the projection
|
||||
// vocabulary {"high","normal","low","unknown"}. The accepted input table is
|
||||
// kept in sync with backend gopkg/mail_priority.PriorityValueToType so that
|
||||
// CLI read-side projection observes the same set of values the server
|
||||
// recognises on write.
|
||||
func mapPriorityValue(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "high", "1 (highest)":
|
||||
return "high"
|
||||
case "3", "normal", "3 (normal)":
|
||||
return "normal"
|
||||
case "5", "low", "5 (lowest)":
|
||||
return "low"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// projectLargeAttachments extracts large attachment info from the draft.
|
||||
// It first tries the server-format header (X-Lark-Large-Attachment) which
|
||||
// carries filename and size directly. Falls back to merging CLI-format
|
||||
|
||||
@@ -178,6 +178,170 @@ func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority projection (X-Cli-Priority primary, X-Priority fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestProjectPriorityXCliPriorityHigh(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority high
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 1
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityFallbackXPriorityLow(t *testing.T) {
|
||||
// Only the standard X-Priority header is present (e.g. an IMAP-回灌
|
||||
// historical draft). The fallback path should kick in.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority low (fallback)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: 5
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "low" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "low")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityBothAbsentNormal(t *testing.T) {
|
||||
// Neither header is present — default priority is normal.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: no priority
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityXCliPriorityOutlookStyleHigh(t *testing.T) {
|
||||
// X-Cli-Priority set to the Outlook-style string "high" (any case).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority high (string)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: High
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityUnmappedValueUnknown(t *testing.T) {
|
||||
// Value outside the recognised mapping table (e.g. "urgent") falls
|
||||
// back to "unknown".
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority urgent
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: urgent
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "unknown" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityXCliPriorityWinsOverXPriority(t *testing.T) {
|
||||
// X-Cli-Priority must take precedence over X-Priority when both are
|
||||
// set (defensive: agent or upstream may write both).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: both headers
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 1
|
||||
X-Priority: 5
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q (X-Cli-Priority must win)", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityNormalThree(t *testing.T) {
|
||||
// X-Cli-Priority=3 → "normal" (rare in CLI write path since
|
||||
// `--set-priority normal` actually removes the header, but this case
|
||||
// covers e.g. a draft set by another OAPI client that wrote 3).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority three
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 3
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityFallbackXPriorityNormalString(t *testing.T) {
|
||||
// IMAP-回灌 / external client writes the RFC-standard `X-Priority: Normal`
|
||||
// string. The fallback path must project this as "normal" — symmetric with
|
||||
// how `X-Priority: High` / `Low` are already handled.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority normal (fallback)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: Normal
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityOutlookStyleThreeNormal(t *testing.T) {
|
||||
// Outlook-style `3 (Normal)` parenthesised form — symmetric with the
|
||||
// already-supported `1 (Highest)` / `5 (Lowest)`.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority three (normal)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: 3 (Normal)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingInlineCIDReportedAsProjectionWarning(t *testing.T) {
|
||||
// Missing CID references should NOT prevent parsing; they are reported
|
||||
// as warnings in Project() instead.
|
||||
|
||||
@@ -2602,3 +2602,14 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd
|
||||
senderEmail, toAddrs, ccAddrs,
|
||||
)
|
||||
}
|
||||
|
||||
// validateBotMailboxNotMe rejects the combination of bot identity with --mailbox me.
|
||||
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
|
||||
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
|
||||
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
|
||||
return output.ErrValidation(
|
||||
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " +
|
||||
"pass an explicit email address, e.g. --mailbox alice@example.com")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -293,6 +293,9 @@ func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID stri
|
||||
if len(projection.Warnings) > 0 {
|
||||
fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; ")))
|
||||
}
|
||||
if projection.Priority != "" {
|
||||
fmt.Fprintf(w, "priority: %s\n", sanitizeForTerminal(projection.Priority))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -553,6 +556,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
"`add_inline`/`replace_inline`/`remove_inline` are for CID-based inline images",
|
||||
"`replace_inline` keeps the original filename and content_type when those fields are omitted",
|
||||
"protected headers require `allow_protected_header_edits=true`",
|
||||
"--set-priority high|normal|low controls draft priority via X-Cli-Priority header (CLI/OAPI specific). high → set_header X-Cli-Priority=1; low → set_header X-Cli-Priority=5; normal → remove_header X-Cli-Priority. Backend mail-data-access headersToPbBodyExtra recognizes X-Cli-Priority but not standard X-Priority/Importance for OAPI flow.",
|
||||
},
|
||||
"command_example": "lark-cli mail +draft-edit --print-patch-template",
|
||||
"patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",
|
||||
|
||||
@@ -26,6 +26,9 @@ var MailMessage = common.Shortcut{
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageID := runtime.Str("message-id")
|
||||
|
||||
@@ -34,6 +34,9 @@ var MailMessages = common.Shortcut{
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
|
||||
130
shortcuts/mail/mail_shortcut_validation_test.go
Normal file
130
shortcuts/mail/mail_shortcut_validation_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertValidationError fails the test unless err is a *output.ExitError with
|
||||
// ExitValidation code whose message contains wantSubstr.
|
||||
func assertValidationError(t *testing.T, err error, wantSubstr string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("expected detail type \"validation\", got %+v", exitErr.Detail)
|
||||
}
|
||||
if wantSubstr != "" && !strings.Contains(exitErr.Error(), wantSubstr) {
|
||||
t.Errorf("expected error message to contain %q, got: %v", wantSubstr, exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidatePasses fails the test if err is a validation error; other
|
||||
// errors (e.g. API call failures from missing tokens) are acceptable because
|
||||
// we only care that the Validate callback passed.
|
||||
func assertValidatePasses(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Code == output.ExitValidation {
|
||||
t.Fatalf("Validate callback should have passed but returned validation error: %v", exitErr)
|
||||
}
|
||||
// Non-validation errors (auth/API failures) are expected without HTTP mocks.
|
||||
}
|
||||
|
||||
// TC-1: +message --as bot --mailbox me → ErrValidation
|
||||
func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "bot", "--mailbox", "me", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-2: +message --as bot --mailbox explicit → Validate passes
|
||||
func TestMailMessageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "bot", "--mailbox", "alice@example.com", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-3: +message --as user --mailbox me → Validate passes
|
||||
func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "user", "--mailbox", "me", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-4: +messages --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-5: +messages --as bot --mailbox explicit → Validate passes
|
||||
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-6: +thread --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailThreadBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailThread, []string{
|
||||
"+thread", "--as", "bot", "--thread-id", "thread_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-7: +thread --as bot --mailbox explicit → Validate passes
|
||||
func TestMailThreadBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailThread, []string{
|
||||
"+thread", "--as", "bot", "--mailbox", "alice@example.com", "--thread-id", "thread_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-8: +triage --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailTriageBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--as", "bot",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-9: +triage --as bot --mailbox explicit → Validate passes
|
||||
func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--as", "bot", "--mailbox", "alice@example.com",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
@@ -75,6 +75,9 @@ var MailTemplateCreate = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ var MailTemplateUpdate = common.Shortcut{
|
||||
if runtime.Bool("print-patch-template") {
|
||||
return nil
|
||||
}
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ var MailThread = common.Shortcut{
|
||||
{Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
threadID := runtime.Str("thread-id")
|
||||
|
||||
@@ -64,6 +64,9 @@ var MailTriage = common.Shortcut{
|
||||
{Name: "labels", Type: "bool", Desc: "include label IDs in output"},
|
||||
{Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
query := runtime.Str("query")
|
||||
|
||||
@@ -5,6 +5,7 @@ package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -112,6 +113,42 @@ func finalMarkdownFileName(spec markdownUploadSpec) string {
|
||||
return filepath.Base(spec.FilePath)
|
||||
}
|
||||
|
||||
func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markdownUploadSpec) (string, error) {
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = filepath.Base(spec.FilePath)
|
||||
}
|
||||
if fileName == "" {
|
||||
remoteName, err := fetchMarkdownFileName(runtime, spec.FileToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileName = strings.TrimSpace(remoteName)
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = spec.FileToken + ".md"
|
||||
}
|
||||
return fileName, nil
|
||||
}
|
||||
|
||||
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func validateNonEmptyMarkdownSize(size int64) error {
|
||||
if size == 0 {
|
||||
return output.ErrValidation("%s", markdownEmptyContentError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
|
||||
var size int64
|
||||
if spec.ContentSet {
|
||||
@@ -127,8 +164,8 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
}
|
||||
size = info.Size()
|
||||
}
|
||||
if size == 0 {
|
||||
return 0, output.ErrValidation("%s", markdownEmptyContentError)
|
||||
if err := validateNonEmptyMarkdownSize(size); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package markdown
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -73,19 +72,9 @@ var MarkdownOverwrite = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = filepath.Base(spec.FilePath)
|
||||
}
|
||||
if fileName == "" {
|
||||
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName = strings.TrimSpace(remoteName)
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = fileToken + ".md"
|
||||
fileName, err := resolveMarkdownOverwriteFileName(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.FileName = fileName
|
||||
|
||||
|
||||
235
shortcuts/markdown/markdown_patch.go
Normal file
235
shortcuts/markdown/markdown_patch.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
markdownPatchModeLiteral = "literal"
|
||||
markdownPatchModeRegex = "regex"
|
||||
)
|
||||
|
||||
type markdownPatchSpec struct {
|
||||
FileToken string
|
||||
Pattern string
|
||||
Content string
|
||||
ContentSet bool
|
||||
Regex bool
|
||||
}
|
||||
|
||||
var MarkdownPatch = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+patch",
|
||||
Description: "Patch a Markdown file in Drive via fetch-local-replace-overwrite",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:download", "drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "target Markdown file token", Required: true},
|
||||
{Name: "pattern", Desc: "literal text or RE2 regex to match", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "replacement Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "regex", Type: "bool", Desc: "interpret --pattern as RE2 regular expression"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newMarkdownPatchSpec(runtime)
|
||||
if err := validateMarkdownPatchSpec(runtime, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Regex {
|
||||
if _, err := regexp.Compile(spec.Pattern); err != nil {
|
||||
return output.ErrValidation("invalid --pattern regex: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newMarkdownPatchSpec(runtime)
|
||||
mode := markdownPatchModeLiteral
|
||||
if spec.Regex {
|
||||
mode = markdownPatchModeRegex
|
||||
}
|
||||
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the current Markdown content").
|
||||
Set("file_token", spec.FileToken).
|
||||
POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": spec.FileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_all").
|
||||
Desc("[3a] If the patched Markdown is at most "+sizeThreshold+", overwrite the file with multipart/form-data upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
|
||||
"parent_type": "explorer",
|
||||
"parent_node": "",
|
||||
"size": "<updated_size_bytes>",
|
||||
"file": "<patched_markdown_content>",
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_prepare").
|
||||
Desc("[3b] If the patched Markdown exceeds "+sizeThreshold+", initialize multipart overwrite upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
|
||||
"parent_type": "explorer",
|
||||
"parent_node": "",
|
||||
"size": "<updated_size_bytes>",
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_part").
|
||||
Desc("[3c] Upload file parts (repeated) when multipart overwrite is required").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_finish").
|
||||
Desc("[3d] Finalize multipart overwrite upload and return the new version").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
}).
|
||||
Set("mode", mode)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newMarkdownPatchSpec(runtime)
|
||||
|
||||
resp, err := openMarkdownDownload(ctx, runtime, spec.FileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
original := string(payload)
|
||||
patched, matchCount, err := applyMarkdownPatch(original, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mode := markdownPatchModeLiteral
|
||||
if spec.Regex {
|
||||
mode = markdownPatchModeRegex
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"updated": false,
|
||||
"mode": mode,
|
||||
"match_count": matchCount,
|
||||
"version": "",
|
||||
"size_bytes_before": len(payload),
|
||||
"size_bytes_after": len(payload),
|
||||
}
|
||||
if matchCount == 0 {
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownPatch(w, out)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
patchedPayload := []byte(patched)
|
||||
if err := validateNonEmptyMarkdownSize(int64(len(patchedPayload))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
specUpload := markdownUploadSpec{
|
||||
FileToken: spec.FileToken,
|
||||
}
|
||||
fileName, err := resolveMarkdownOverwriteFileName(runtime, specUpload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specUpload.FileName = fileName
|
||||
|
||||
result, err := uploadMarkdownContent(runtime, specUpload, patchedPayload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out["updated"] = true
|
||||
out["version"] = result.Version
|
||||
out["size_bytes_after"] = len(patchedPayload)
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownPatch(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func newMarkdownPatchSpec(runtime *common.RuntimeContext) markdownPatchSpec {
|
||||
return markdownPatchSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
Pattern: runtime.Str("pattern"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
Regex: runtime.Bool("regex"),
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkdownPatchSpec(runtime *common.RuntimeContext, spec markdownPatchSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if !runtime.Changed("pattern") {
|
||||
return common.FlagErrorf("--pattern is required")
|
||||
}
|
||||
if spec.Pattern == "" {
|
||||
return output.ErrValidation("--pattern cannot be empty")
|
||||
}
|
||||
if !spec.ContentSet {
|
||||
return common.FlagErrorf("--content is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, error) {
|
||||
if !spec.Regex {
|
||||
return strings.ReplaceAll(original, spec.Pattern, spec.Content), strings.Count(original, spec.Pattern), nil
|
||||
}
|
||||
re, err := regexp.Compile(spec.Pattern)
|
||||
if err != nil {
|
||||
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
|
||||
}
|
||||
matches := re.FindAllStringIndex(original, -1)
|
||||
return re.ReplaceAllString(original, spec.Content), len(matches), nil
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownPatch(w io.Writer, data map[string]interface{}) {
|
||||
updated := common.GetBool(data, "updated")
|
||||
if updated {
|
||||
io.WriteString(w, "updated: true\n")
|
||||
} else {
|
||||
io.WriteString(w, "updated: false\n")
|
||||
}
|
||||
io.WriteString(w, "mode: "+common.GetString(data, "mode")+"\n")
|
||||
fmt.Fprintf(w, "match_count: %d\n", common.GetInt(data, "match_count"))
|
||||
if version := common.GetString(data, "version"); version != "" {
|
||||
io.WriteString(w, "version: "+version+"\n")
|
||||
}
|
||||
fmt.Fprintf(w, "size_bytes_before: %d\n", common.GetInt(data, "size_bytes_before"))
|
||||
fmt.Fprintf(w, "size_bytes_after: %d\n", common.GetInt(data, "size_bytes_after"))
|
||||
}
|
||||
564
shortcuts/markdown/markdown_patch_test.go
Normal file
564
shortcuts/markdown/markdown_patch_test.go
Normal file
@@ -0,0 +1,564 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestMarkdownPatchValidation(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "pattern is required",
|
||||
args: []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--content", "DONE",
|
||||
},
|
||||
want: "--pattern is required",
|
||||
},
|
||||
{
|
||||
name: "pattern cannot be empty",
|
||||
args: []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", "",
|
||||
"--content", "DONE",
|
||||
},
|
||||
want: "--pattern cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "content is required",
|
||||
args: []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", "TODO",
|
||||
},
|
||||
want: "--content is required",
|
||||
},
|
||||
{
|
||||
name: "invalid regex",
|
||||
args: []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--regex",
|
||||
"--pattern", "(",
|
||||
"--content", "DONE",
|
||||
},
|
||||
want: "invalid --pattern regex",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, tt.args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchDryRunLiteral(t *testing.T) {
|
||||
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", "TODO", "DONE", false)
|
||||
|
||||
if got := dry.Mode; got != markdownPatchModeLiteral {
|
||||
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
|
||||
}
|
||||
if got := len(dry.API); got != 6 {
|
||||
t.Fatalf("api steps = %d, want 6", got)
|
||||
}
|
||||
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
|
||||
t.Fatalf("download url = %q", got)
|
||||
}
|
||||
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metas url = %q", got)
|
||||
}
|
||||
if got := dry.API[2].URL; got != "/open-apis/drive/v1/files/upload_all" {
|
||||
t.Fatalf("upload_all url = %q", got)
|
||||
}
|
||||
if got := dry.API[3].URL; got != "/open-apis/drive/v1/files/upload_prepare" {
|
||||
t.Fatalf("upload_prepare url = %q", got)
|
||||
}
|
||||
if got := dry.API[4].URL; got != "/open-apis/drive/v1/files/upload_part" {
|
||||
t.Fatalf("upload_part url = %q", got)
|
||||
}
|
||||
if got := dry.API[5].URL; got != "/open-apis/drive/v1/files/upload_finish" {
|
||||
t.Fatalf("upload_finish url = %q", got)
|
||||
}
|
||||
if got := dry.API[2].Body["file_token"]; got != "box_md_patch" {
|
||||
t.Fatalf("upload_all file_token = %#v", got)
|
||||
}
|
||||
if got := dry.API[3].Body["file_token"]; got != "box_md_patch" {
|
||||
t.Fatalf("upload_prepare file_token = %#v", got)
|
||||
}
|
||||
if got := dry.API[2].Body["file"]; got != "<patched_markdown_content>" {
|
||||
t.Fatalf("upload_all file placeholder = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchDryRunRegex(t *testing.T) {
|
||||
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", `Version: ([0-9]+)`, `Version: $1`, true)
|
||||
|
||||
if got := dry.Mode; got != markdownPatchModeRegex {
|
||||
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
|
||||
}
|
||||
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
|
||||
t.Fatalf("download desc = %q", got)
|
||||
}
|
||||
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
|
||||
t.Fatalf("upload_prepare desc = %q", got)
|
||||
}
|
||||
if got := dry.API[5].Body["block_num"]; got != "<block_num>" {
|
||||
t.Fatalf("upload_finish block_num = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMarkdownPatchSpecRejectsInvalidFileToken(t *testing.T) {
|
||||
runtime := newMarkdownPatchRuntime(t, "../bad", "TODO", "DONE", false)
|
||||
|
||||
err := validateMarkdownPatchSpec(runtime, newMarkdownPatchSpec(runtime))
|
||||
if err == nil || !strings.Contains(err.Error(), "--file-token must not contain '..' path traversal") {
|
||||
t.Fatalf("expected invalid file-token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", "TODO",
|
||||
"--content", "DONE",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeMarkdownEnvelope(t, stdout)
|
||||
if common.GetBool(data, "updated") {
|
||||
t.Fatalf("updated = true, want false")
|
||||
}
|
||||
if got := common.GetString(data, "mode"); got != markdownPatchModeLiteral {
|
||||
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
|
||||
}
|
||||
if got := common.GetInt(data, "match_count"); got != 0 {
|
||||
t.Fatalf("match_count = %d, want 0", got)
|
||||
}
|
||||
if got := common.GetString(data, "version"); got != "" {
|
||||
t.Fatalf("version = %q, want empty", got)
|
||||
}
|
||||
if got := common.GetInt(data, "size_bytes_before"); got != len("# hello\n") {
|
||||
t.Fatalf("size_bytes_before = %d, want %d", got, len("# hello\n"))
|
||||
}
|
||||
if got := common.GetInt(data, "size_bytes_after"); got != len("# hello\n") {
|
||||
t.Fatalf("size_bytes_after = %d, want %d", got, len("# hello\n"))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"matches"`) {
|
||||
t.Fatalf("stdout should not include matches field: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", "TODO",
|
||||
"--content", "DONE",
|
||||
"--format", "pretty",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"updated: false",
|
||||
"mode: literal",
|
||||
"match_count: 0",
|
||||
"size_bytes_before: 8",
|
||||
"size_bytes_after: 8",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "version:") {
|
||||
t.Fatalf("pretty output should omit version when unchanged:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\nTODO\n"),
|
||||
Headers: map[string][]string{
|
||||
"Content-Disposition": {`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"title": "README.md"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_patch",
|
||||
"version": "7633658129540910626",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", "TODO",
|
||||
"--content", "DONE",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != "box_md_patch" {
|
||||
t.Fatalf("file_token = %q, want box_md_patch", got)
|
||||
}
|
||||
if got := body.Fields["file_name"]; got != "README.md" {
|
||||
t.Fatalf("file_name = %q, want README.md", got)
|
||||
}
|
||||
if got := string(body.Files["file"]); got != "# DONE\nDONE\n" {
|
||||
t.Fatalf("uploaded file content = %q", got)
|
||||
}
|
||||
|
||||
data := decodeMarkdownEnvelope(t, stdout)
|
||||
if !common.GetBool(data, "updated") {
|
||||
t.Fatalf("updated = false, want true")
|
||||
}
|
||||
if got := common.GetInt(data, "match_count"); got != 2 {
|
||||
t.Fatalf("match_count = %d, want 2", got)
|
||||
}
|
||||
if got := common.GetString(data, "version"); got != "7633658129540910626" {
|
||||
t.Fatalf("version = %q, want 7633658129540910626", got)
|
||||
}
|
||||
if got := common.GetInt(data, "size_bytes_before"); got != len("# TODO\nTODO\n") {
|
||||
t.Fatalf("size_bytes_before = %d, want %d", got, len("# TODO\nTODO\n"))
|
||||
}
|
||||
if got := common.GetInt(data, "size_bytes_after"); got != len("# DONE\nDONE\n") {
|
||||
t.Fatalf("size_bytes_after = %d, want %d", got, len("# DONE\nDONE\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\n"),
|
||||
Headers: map[string][]string{
|
||||
"Content-Disposition": {`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"title": "README.md"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_patch",
|
||||
"version": "9001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", "TODO",
|
||||
"--content", "DONE",
|
||||
"--format", "pretty",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"updated: true",
|
||||
"mode: literal",
|
||||
"match_count: 1",
|
||||
"version: 9001",
|
||||
"size_bytes_before: 7",
|
||||
"size_bytes_after: 7",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchRegexOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("Version: 12\nVersion: 34\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"title": "version.md"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_patch",
|
||||
"version": "7633658129540910627",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--regex",
|
||||
"--pattern", `Version: ([0-9]+)`,
|
||||
"--content", `Version: $1 (patched)`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedMultipartBody(t, uploadStub)
|
||||
if got := string(body.Files["file"]); got != "Version: 12 (patched)\nVersion: 34 (patched)\n" {
|
||||
t.Fatalf("uploaded file content = %q", got)
|
||||
}
|
||||
|
||||
data := decodeMarkdownEnvelope(t, stdout)
|
||||
if got := common.GetString(data, "mode"); got != markdownPatchModeRegex {
|
||||
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
|
||||
}
|
||||
if got := common.GetInt(data, "match_count"); got != 2 {
|
||||
t.Fatalf("match_count = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMarkdownPatchRejectsInvalidRegex(t *testing.T) {
|
||||
_, _, err := applyMarkdownPatch("hello", markdownPatchSpec{
|
||||
Pattern: "(",
|
||||
Content: "DONE",
|
||||
Regex: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --pattern regex") {
|
||||
t.Fatalf("expected invalid regex error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello world\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"title": "hello.md"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_patch",
|
||||
"version": "7633658129540910628",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", " world",
|
||||
"--content", "",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedMultipartBody(t, uploadStub)
|
||||
if got := string(body.Files["file"]); got != "hello\n" {
|
||||
t.Fatalf("uploaded file content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello\n"),
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
|
||||
"+patch",
|
||||
"--file-token", "box_md_patch",
|
||||
"--pattern", "hello\n",
|
||||
"--content", "",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "empty markdown content is not supported") {
|
||||
t.Fatalf("expected empty content validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nstdout:\n%s", err, stdout.String())
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
type markdownPatchDryRunOutput struct {
|
||||
Mode string `json:"mode"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
func newMarkdownPatchRuntime(t *testing.T, fileToken, pattern, content string, regex bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "markdown +patch"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("content", "", "")
|
||||
cmd.Flags().Bool("regex", false, "")
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"file-token": fileToken,
|
||||
"pattern": pattern,
|
||||
"content": content,
|
||||
} {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if regex {
|
||||
if err := cmd.Flags().Set("regex", "true"); err != nil {
|
||||
t.Fatalf("set --regex: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return common.TestNewRuntimeContext(cmd, markdownTestConfig())
|
||||
}
|
||||
|
||||
func decodeMarkdownPatchDryRun(t *testing.T, fileToken, pattern, content string, regex bool) markdownPatchDryRunOutput {
|
||||
t.Helper()
|
||||
|
||||
runtime := newMarkdownPatchRuntime(t, fileToken, pattern, content, regex)
|
||||
dry := MarkdownPatch.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run json: %v", err)
|
||||
}
|
||||
|
||||
var out markdownPatchDryRunOutput
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("unmarshal dry-run json: %v\njson=%s", err, string(data))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{"+create", "+fetch", "+overwrite"}
|
||||
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
|
||||
|
||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownFetch,
|
||||
MarkdownPatch,
|
||||
MarkdownOverwrite,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ var SheetSetStyle = common.Shortcut{
|
||||
}
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
_ = json.Unmarshal([]byte(runtime.Str("style")), &style) // Validate already parses and validates this JSON.
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
|
||||
Body(map[string]interface{}{
|
||||
@@ -164,7 +164,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
_ = json.Unmarshal([]byte(runtime.Str("data")), &data) // Validate already parses and validates this JSON via validateBatchStyleData().
|
||||
normalizeBatchStyleRanges(data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
|
||||
@@ -12,7 +12,10 @@ func Shortcuts() []common.Shortcut {
|
||||
WikiNodeCreate,
|
||||
WikiDeleteSpace,
|
||||
WikiSpaceList,
|
||||
WikiSpaceCreate,
|
||||
WikiNodeList,
|
||||
WikiNodeCopy,
|
||||
WikiNodeGet,
|
||||
WikiNodeDelete,
|
||||
}
|
||||
}
|
||||
|
||||
207
shortcuts/wiki/wiki_async_task.go
Normal file
207
shortcuts/wiki/wiki_async_task.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Shared async-task polling for wiki delete operations. The wiki delete
|
||||
// endpoints (DELETE /spaces/{id}, DELETE /spaces/{id}/nodes/{token}) may
|
||||
// return either an empty task_id (sync completion) or a task_id that must
|
||||
// be polled against /wiki/v2/tasks/{task_id}?task_type=<...>.
|
||||
//
|
||||
// For historical reasons /wiki/v2/tasks/{task_id} stashes the status under a
|
||||
// different key per task type: delete-space uses `delete_space_result`, while
|
||||
// delete-node uses the generic `simple_task_result` (the gateway's reusable
|
||||
// "future async tasks share this" field). move tasks use `move_result` and are
|
||||
// handled separately in wiki_move.go. Every key still exposes a `status`, so
|
||||
// the poll loop / classification is factored out here and the caller passes
|
||||
// the right result key.
|
||||
//
|
||||
// Note: `simple_task_result` only carries `status` (no `status_msg`), so for
|
||||
// delete-node StatusLabel() falls back to the status code — which is fine.
|
||||
|
||||
const (
|
||||
wikiAsyncStatusSuccess = "success"
|
||||
wikiAsyncStatusFailure = "failure"
|
||||
wikiAsyncStatusProcessing = "processing"
|
||||
|
||||
wikiAsyncTaskTypeDeleteSpace = "delete_space"
|
||||
wikiAsyncTaskTypeDeleteNode = "delete_node"
|
||||
|
||||
wikiAsyncResultDeleteSpace = "delete_space_result"
|
||||
// wikiAsyncResultSimpleTask is the generic result key the gateway uses for
|
||||
// delete-node (and intends to reuse for future async task types). It is
|
||||
// NOT `delete_node_result` — that key does not exist in the response.
|
||||
wikiAsyncResultSimpleTask = "simple_task_result"
|
||||
)
|
||||
|
||||
// wikiAsyncTaskStatus is the unified poll-response shape used by every wiki
|
||||
// delete task. The taskID is captured so error/resume hints can name it.
|
||||
type wikiAsyncTaskStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
// normalizedStatus collapses whitespace and case so " SUCCESS " classifies
|
||||
// the same as "success". Ready()/Failed() (control flow) derive from this;
|
||||
// StatusCode()/StatusLabel() (display) deliberately surface the raw backend
|
||||
// value instead. For the real status enums (delete-node: processing/success/
|
||||
// failed; delete-space's documented set) the two agree. They only diverge for
|
||||
// an undocumented status string, which is intentional — an unrecognized status
|
||||
// is shown verbatim rather than masked as a hard failure.
|
||||
func (s wikiAsyncTaskStatus) normalizedStatus() string {
|
||||
return strings.ToLower(strings.TrimSpace(s.Status))
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) Ready() bool {
|
||||
return s.normalizedStatus() == wikiAsyncStatusSuccess
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) Failed() bool {
|
||||
// The sample protocol only documents "success" as a terminal OK. Treat any
|
||||
// explicit "failure"/"failed" signal as terminal, and unknown non-success
|
||||
// values as still-processing so we don't misreport a novel status as a hard
|
||||
// failure.
|
||||
lowered := s.normalizedStatus()
|
||||
return lowered == wikiAsyncStatusFailure || lowered == "failed"
|
||||
}
|
||||
|
||||
// StatusCode returns a never-empty status value for the output envelope. If
|
||||
// the backend response omits delete_*_result.status (or sends whitespace),
|
||||
// fall back to "processing" so the documented timeout-shape stays accurate.
|
||||
func (s wikiAsyncTaskStatus) StatusCode() string {
|
||||
if status := strings.TrimSpace(s.Status); status != "" {
|
||||
return status
|
||||
}
|
||||
return wikiAsyncStatusProcessing
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) StatusLabel() string {
|
||||
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return s.StatusCode()
|
||||
}
|
||||
|
||||
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
|
||||
// translate from runtime.CallAPI responses or test fakes.
|
||||
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
|
||||
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
|
||||
// resultKey selects the right shape ("delete_space_result" for delete-space,
|
||||
// "simple_task_result" for delete-node).
|
||||
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, resultKey)
|
||||
status := wikiAsyncTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
if result != nil {
|
||||
status.Status = common.GetString(result, "status")
|
||||
status.StatusMsg = common.GetString(result, "status_msg")
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// pollWikiAsyncTask runs the bounded polling loop shared by every wiki delete
|
||||
// shortcut. label is the human-readable operation name surfaced in stderr
|
||||
// progress lines ("delete-space" / "delete-node"). nextCommand is the resume
|
||||
// hint embedded into the wrapped error when every poll fails.
|
||||
//
|
||||
// attempts/interval are taken as parameters (instead of consts) so callers
|
||||
// can keep their per-operation tunable constants for back-compat with the
|
||||
// existing test hooks.
|
||||
func pollWikiAsyncTask(
|
||||
ctx context.Context,
|
||||
runtime *common.RuntimeContext,
|
||||
taskID, label string,
|
||||
attempts int,
|
||||
interval time.Duration,
|
||||
fetcher wikiAsyncTaskFetcher,
|
||||
nextCommand string,
|
||||
) (wikiAsyncTaskStatus, bool, error) {
|
||||
lastStatus := wikiAsyncTaskStatus{TaskID: taskID}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
// The delete request already succeeded. Treat poll failures as transient
|
||||
// until every attempt fails, then return a resume hint instead of
|
||||
// discarding the task identifier.
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := fetcher(ctx, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status attempt %d/%d failed: %v\n", label, attempt, attempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s task completed successfully.\n", label)
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
label, taskID, nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
// ErrWithHint rebuilds the error and drops the upstream Lark
|
||||
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
|
||||
// ExitError by hand so the original API code survives a fully
|
||||
// failed poll, matching wrapWikiNodeDeleteAPIError.
|
||||
return lastStatus, false, &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
181
shortcuts/wiki/wiki_async_task_test.go
Normal file
181
shortcuts/wiki/wiki_async_task_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
|
||||
// so it gets a dedicated test surface here rather than relying only on the
|
||||
// transitive coverage from the delete-space / delete-node paths.
|
||||
|
||||
func TestPollWikiAsyncTaskSuccessFirstPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_ok", "delete-node", 3, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "success"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pollWikiAsyncTask() error = %v", err)
|
||||
}
|
||||
if !ready || !status.Ready() {
|
||||
t.Fatalf("ready = %v, status = %+v, want ready", ready, status)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "delete-node task completed successfully") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskFailureIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_x", "delete-node", 3, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "failure", StatusMsg: "denied"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on failure")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "delete-node task task_x failed: denied") {
|
||||
t.Fatalf("err = %v, want terminal failure with reason", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskTimeoutWhenAlwaysProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_slow", "delete-space", 2, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "processing"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
// A still-processing task after the bounded window is a soft timeout:
|
||||
// no error, ready=false, status preserved so the caller can print the
|
||||
// follow-up command.
|
||||
if err != nil {
|
||||
t.Fatalf("pollWikiAsyncTask() error = %v, want nil on timeout", err)
|
||||
}
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on timeout")
|
||||
}
|
||||
if status.StatusCode() != "processing" {
|
||||
t.Fatalf("status = %+v, want processing preserved", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{}, errors.New("transport boom")
|
||||
},
|
||||
"lark-cli drive +task_result --task-id task_lost",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false when every poll failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
|
||||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
|
||||
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
|
||||
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
upstream := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991663,
|
||||
Message: "permission denied",
|
||||
Hint: "grant the wiki:node:retrieve scope",
|
||||
},
|
||||
}
|
||||
_, _, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{}, upstream
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
|
||||
}
|
||||
// The upstream hint must lead so the actionable cause is read first, with
|
||||
// the resume guidance appended. Type and exit code propagate from upstream.
|
||||
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
|
||||
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
|
||||
}
|
||||
if exitErr.Detail.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskHonoursContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
ctx, runtime, "task_cancel", "delete-node", 5, time.Hour,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
calls++
|
||||
cancel() // cancel before the next attempt's inter-poll wait
|
||||
return wikiAsyncTaskStatus{Status: "processing"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on cancellation")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fetcher calls = %d, want 1 (cancelled before second poll)", calls)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,10 +20,12 @@ var (
|
||||
wikiDeleteSpacePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// Back-compat aliases — the shared async-task helper now owns the strings,
|
||||
// but tests still reference these names.
|
||||
const (
|
||||
wikiDeleteSpaceStatusSuccess = "success"
|
||||
wikiDeleteSpaceStatusFailure = "failure"
|
||||
wikiDeleteSpaceStatusProcessing = "processing"
|
||||
wikiDeleteSpaceStatusSuccess = wikiAsyncStatusSuccess
|
||||
wikiDeleteSpaceStatusFailure = wikiAsyncStatusFailure
|
||||
wikiDeleteSpaceStatusProcessing = wikiAsyncStatusProcessing
|
||||
)
|
||||
|
||||
// WikiDeleteSpace deletes a wiki space. The DELETE endpoint may complete
|
||||
@@ -73,48 +74,10 @@ type wikiDeleteSpaceResponse struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
type wikiDeleteSpaceTaskStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
// normalizedStatus collapses whitespace and case so " SUCCESS " is
|
||||
// classified the same as "success". Ready / Failed / StatusCode all derive
|
||||
// from this so classification and the output `status` field can't disagree.
|
||||
func (s wikiDeleteSpaceTaskStatus) normalizedStatus() string {
|
||||
return strings.ToLower(strings.TrimSpace(s.Status))
|
||||
}
|
||||
|
||||
func (s wikiDeleteSpaceTaskStatus) Ready() bool {
|
||||
return s.normalizedStatus() == wikiDeleteSpaceStatusSuccess
|
||||
}
|
||||
|
||||
func (s wikiDeleteSpaceTaskStatus) Failed() bool {
|
||||
// The sample protocol only documents "success" as a terminal OK. Treat any
|
||||
// explicit "failure"/"failed" signal as terminal, and unknown non-success
|
||||
// values as still-processing so we don't misreport a novel status as a hard
|
||||
// failure.
|
||||
lowered := s.normalizedStatus()
|
||||
return lowered == wikiDeleteSpaceStatusFailure || lowered == "failed"
|
||||
}
|
||||
|
||||
// StatusCode returns a never-empty status value for the output envelope. If
|
||||
// the backend response omits delete_space_result.status (or sends whitespace),
|
||||
// fall back to "processing" so the documented timeout-shape stays accurate.
|
||||
func (s wikiDeleteSpaceTaskStatus) StatusCode() string {
|
||||
if status := strings.TrimSpace(s.Status); status != "" {
|
||||
return status
|
||||
}
|
||||
return wikiDeleteSpaceStatusProcessing
|
||||
}
|
||||
|
||||
func (s wikiDeleteSpaceTaskStatus) StatusLabel() string {
|
||||
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return s.StatusCode()
|
||||
}
|
||||
// wikiDeleteSpaceTaskStatus is an alias for the shared wiki async-task shape;
|
||||
// kept as a named type for the existing test surface. delete-node uses the
|
||||
// same type directly under its real name (wikiAsyncTaskStatus).
|
||||
type wikiDeleteSpaceTaskStatus = wikiAsyncTaskStatus
|
||||
|
||||
type wikiDeleteSpaceClient interface {
|
||||
DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error)
|
||||
@@ -150,7 +113,7 @@ func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID str
|
||||
if err != nil {
|
||||
return wikiDeleteSpaceTaskStatus{}, err
|
||||
}
|
||||
return parseWikiDeleteSpaceTaskStatus(taskID, common.GetMap(data, "task"))
|
||||
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultDeleteSpace)
|
||||
}
|
||||
|
||||
func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec {
|
||||
@@ -237,77 +200,18 @@ func wikiDeleteSpaceTaskResultCommand(taskID string, identity core.Identity) str
|
||||
}
|
||||
|
||||
func pollWikiDeleteSpaceTask(ctx context.Context, client wikiDeleteSpaceClient, runtime *common.RuntimeContext, taskID string) (wikiDeleteSpaceTaskStatus, bool, error) {
|
||||
lastStatus := wikiDeleteSpaceTaskStatus{TaskID: taskID}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
// The delete request already succeeded. Treat poll failures as transient
|
||||
// until every attempt fails, then return a resume hint instead of discarding
|
||||
// the task identifier.
|
||||
for attempt := 1; attempt <= wikiDeleteSpacePollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-time.After(wikiDeleteSpacePollInterval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := client.GetDeleteSpaceTask(ctx, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status attempt %d/%d failed: %v\n", attempt, wikiDeleteSpacePollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki delete-space task %s failed: %s", taskID, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status %d/%d: %s\n", attempt, wikiDeleteSpacePollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
nextCommand := wikiDeleteSpaceTaskResultCommand(taskID, runtime.As())
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki delete-space task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
taskID,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
return pollWikiAsyncTask(
|
||||
ctx, runtime, taskID, "delete-space",
|
||||
wikiDeleteSpacePollAttempts, wikiDeleteSpacePollInterval,
|
||||
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
|
||||
return client.GetDeleteSpaceTask(ctx, id)
|
||||
},
|
||||
wikiDeleteSpaceTaskResultCommand(taskID, runtime.As()),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiDeleteSpaceTaskStatus is kept as a thin wrapper for the existing
|
||||
// test surface; new callers should use parseWikiAsyncTaskStatus directly.
|
||||
func parseWikiDeleteSpaceTaskStatus(taskID string, task map[string]interface{}) (wikiDeleteSpaceTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiDeleteSpaceTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, "delete_space_result")
|
||||
status := wikiDeleteSpaceTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
if result != nil {
|
||||
status.Status = common.GetString(result, "status")
|
||||
status.StatusMsg = common.GetString(result, "status_msg")
|
||||
}
|
||||
return status, nil
|
||||
return parseWikiAsyncTaskStatus(taskID, task, wikiAsyncResultDeleteSpace)
|
||||
}
|
||||
|
||||
@@ -266,8 +266,19 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
withSingleWikiDeleteSpacePoll(t)
|
||||
|
||||
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
|
||||
// Seed an error that carries an upstream Lark Detail.Code so the test
|
||||
// pins that structured fields survive a fully failed poll (not just the
|
||||
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
|
||||
client := &fakeWikiDeleteSpaceClient{
|
||||
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
|
||||
taskErrs: []error{&output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 131006,
|
||||
Message: "poll failed",
|
||||
Hint: "retry original",
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
|
||||
@@ -287,6 +298,9 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Code != 131006 {
|
||||
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
}
|
||||
|
||||
28
shortcuts/wiki/wiki_helpers.go
Normal file
28
shortcuts/wiki/wiki_helpers.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeURL returns the user-facing link for a wiki node. The create/copy
|
||||
// OpenAPI responses carry a real `url` (undocumented in the server-docs schema
|
||||
// but present in practice); prefer it so the CLI surfaces the canonical link.
|
||||
// Fall back to BuildResourceURL synthesis only when the response omits it.
|
||||
//
|
||||
// Shared by +node-create and +node-copy, hence kept here rather than in either
|
||||
// command's file.
|
||||
func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if u := strings.TrimSpace(node.URL); u != "" {
|
||||
return u
|
||||
}
|
||||
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
||||
}
|
||||
@@ -415,6 +415,7 @@ func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
|
||||
"node_type": "origin",
|
||||
"title": "Architecture (Copy)",
|
||||
"has_child": false,
|
||||
"url": "https://abc.feishu.cn/wiki/wik_copied_real",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
@@ -451,6 +452,9 @@ func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
|
||||
if envelope.Data["space_id"] != "space_dst" {
|
||||
t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst")
|
||||
}
|
||||
if got, want := envelope.Data["url"], "https://abc.feishu.cn/wiki/wik_copied_real"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (copy must surface the response url)", got, want)
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
|
||||
|
||||
@@ -89,6 +89,9 @@ var WikiNodeCopy = common.Shortcut{
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n",
|
||||
common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID))
|
||||
out := wikiNodeCopyOutput(node)
|
||||
if u := wikiNodeURL(runtime.Config.Brand, node); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderWikiNodeCopyPretty(w, out)
|
||||
})
|
||||
@@ -106,6 +109,9 @@ func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) {
|
||||
if parent, _ := out["parent_node_token"].(string); parent != "" {
|
||||
fmt.Fprintf(w, " parent_node_token: %s\n", parent)
|
||||
}
|
||||
if url, _ := out["url"].(string); url != "" {
|
||||
fmt.Fprintf(w, " url: %s\n", url)
|
||||
}
|
||||
}
|
||||
|
||||
func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
|
||||
@@ -118,6 +118,7 @@ type wikiNodeRecord struct {
|
||||
OriginNodeToken string
|
||||
Title string
|
||||
HasChild bool
|
||||
URL string
|
||||
}
|
||||
|
||||
// wikiSpaceRecord contains the response fields used when resolving spaces.
|
||||
@@ -456,6 +457,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
|
||||
OriginNodeToken: common.GetString(node, "origin_node_token"),
|
||||
Title: common.GetString(node, "title"),
|
||||
HasChild: common.GetBool(node, "has_child"),
|
||||
URL: common.GetString(node, "url"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -498,7 +500,7 @@ func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wiki
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "wiki", execution.Node.NodeToken); u != "" {
|
||||
if u := wikiNodeURL(runtime.Config.Brand, execution.Node); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -107,24 +107,6 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func TestWikiShortcutsIncludeAllCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 6 {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts))
|
||||
}
|
||||
if shortcuts[0].Command != "+move" {
|
||||
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")
|
||||
}
|
||||
if shortcuts[1].Command != "+node-create" {
|
||||
t.Fatalf("shortcuts[1].Command = %q, want %q", shortcuts[1].Command, "+node-create")
|
||||
}
|
||||
if shortcuts[2].Command != "+delete-space" {
|
||||
t.Fatalf("shortcuts[2].Command = %q, want %q", shortcuts[2].Command, "+delete-space")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -469,6 +451,7 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
|
||||
"origin_node_token": "",
|
||||
"title": "Wiki Node",
|
||||
"has_child": false,
|
||||
"url": "https://abc.feishu.cn/wiki/wik_created_real",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
@@ -502,8 +485,8 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
|
||||
if envelope.Data["node_token"] != "wik_created" {
|
||||
t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created")
|
||||
}
|
||||
if got, want := envelope.Data["url"], "https://www.feishu.cn/wiki/wik_created"; got != want {
|
||||
t.Fatalf("url = %#v, want %q", got, want)
|
||||
if got, want := envelope.Data["url"], "https://abc.feishu.cn/wiki/wik_created_real"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (response url must win over synthesized fallback)", got, want)
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
@@ -646,3 +629,47 @@ func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) {
|
||||
t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
node *wikiNodeRecord
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers response url over synthesized fallback",
|
||||
node: &wikiNodeRecord{NodeToken: "wik_token", URL: "https://abc.feishu.cn/wiki/wik_real"},
|
||||
want: "https://abc.feishu.cn/wiki/wik_real",
|
||||
},
|
||||
{
|
||||
name: "falls back to synthesized url when response omits it",
|
||||
node: &wikiNodeRecord{NodeToken: "wik_token"},
|
||||
want: "https://www.feishu.cn/wiki/wik_token",
|
||||
},
|
||||
{
|
||||
name: "blank response url is treated as absent",
|
||||
node: &wikiNodeRecord{NodeToken: "wik_token", URL: " "},
|
||||
want: "https://www.feishu.cn/wiki/wik_token",
|
||||
},
|
||||
{
|
||||
name: "nil node yields empty string",
|
||||
node: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no token and no url yields empty string",
|
||||
node: &wikiNodeRecord{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := wikiNodeURL(core.BrandFeishu, tc.node); got != tc.want {
|
||||
t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
440
shortcuts/wiki/wiki_node_delete.go
Normal file
440
shortcuts/wiki/wiki_node_delete.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeDeleteObjTypes is the set of obj_type values the delete-node API
|
||||
// accepts. Unlike wikiNodeGetObjTypeEnum this includes "wiki" — for
|
||||
// delete-node, obj_type="wiki" means the token is a wiki node_token, whereas
|
||||
// the get_node API omits obj_type for node_tokens.
|
||||
var wikiNodeDeleteObjTypes = []string{
|
||||
"wiki", "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
|
||||
}
|
||||
|
||||
var (
|
||||
wikiDeleteNodePollAttempts = 30
|
||||
wikiDeleteNodePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// Lark wiki API error codes the delete-node API surfaces with actionable
|
||||
// CLI workarounds. The full list is in the OpenAPI spec; we only special-case
|
||||
// the codes whose remediation is non-obvious (UI approval, subtree size).
|
||||
const (
|
||||
wikiDeleteNodeErrCodeApprovalRequired = 131011
|
||||
wikiDeleteNodeErrCodeSubtreeTooLarge = 131003
|
||||
)
|
||||
|
||||
// WikiNodeDelete deletes a wiki node (or pulls a cloud doc out of Wiki). The
|
||||
// API mirrors +delete-space — synchronous on small deletes, async with a
|
||||
// task_id for cascade deletes — so this shortcut shares the async-polling
|
||||
// helper. Space ID is optional: when omitted, +node-delete first looks up the
|
||||
// node via get_node to resolve the space ID so callers do not have to chain
|
||||
// commands.
|
||||
var WikiNodeDelete = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-delete",
|
||||
Description: "Delete a wiki node, polling the async delete task when needed",
|
||||
Risk: "high-risk-write",
|
||||
// API spec lists wiki:node:create as the only declared scope for the
|
||||
// delete endpoint. Naming is unfortunate, but the scope-preflight needs
|
||||
// the literal string.
|
||||
Scopes: []string{"wiki:node:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "node-token", Desc: "wiki node_token, cloud-doc obj_token, or a Lark URL embedding one of them", Required: true},
|
||||
// Not Required at the cobra level: URL inputs auto-infer obj_type
|
||||
// from the path, and the parser enforces explicit obj_type for raw
|
||||
// tokens. Forcing Cobra Required here breaks the URL ergonomic.
|
||||
{Name: "obj-type", Desc: "token kind; no default — pass explicitly when --node-token is a raw token (URL inputs auto-infer)", Enum: wikiNodeDeleteObjTypes},
|
||||
{Name: "space-id", Desc: "wiki space ID; auto-resolved via get_node when omitted"},
|
||||
{Name: "include-children", Type: "bool", Default: "true", Desc: "cascade delete the subtree (default); pass --include-children=false to lift direct children up to the parent"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Deletion is irreversible; double-check --node-token and --obj-type before running.",
|
||||
"This is a high-risk-write command; pass --yes to confirm the deletion.",
|
||||
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>; URL paths also imply --obj-type.",
|
||||
"Run +node-get first to confirm space_id / obj_type when in doubt.",
|
||||
"Auto-resolving space_id (when --space-id is omitted) also calls get_node, which needs the wiki:node:retrieve scope; pass --space-id to skip that lookup if your token only carries wiki:node:create.",
|
||||
"Async deletes return a task_id; this command polls for a bounded window and then prints a follow-up drive +task_result command.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiNodeDeleteSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeDeleteSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildWikiNodeDeleteDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiNodeDeleteSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(ctx, wikiNodeDeleteAPI{runtime: runtime}, runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiNodeDeleteSpec is the normalized input for the shortcut. Token / ObjType
|
||||
// reconcile URL inputs with the explicit flags; SourceKind is purely for the
|
||||
// dry-run description string.
|
||||
type wikiNodeDeleteSpec struct {
|
||||
NodeToken string
|
||||
ObjType string
|
||||
SpaceID string
|
||||
IncludeChildren bool
|
||||
SourceKind string // "raw" | "url"
|
||||
}
|
||||
|
||||
// RequestBody builds the JSON body for DELETE /spaces/{id}/nodes/{token}.
|
||||
func (spec wikiNodeDeleteSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"obj_type": spec.ObjType,
|
||||
"include_children": spec.IncludeChildren,
|
||||
}
|
||||
}
|
||||
|
||||
// wikiNodeDeleteClient isolates the network operations so business logic can
|
||||
// be unit-tested without real HTTP calls. Mirrors wikiDeleteSpaceClient.
|
||||
type wikiNodeDeleteClient interface {
|
||||
ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error)
|
||||
DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error)
|
||||
GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
}
|
||||
|
||||
type wikiNodeDeleteAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
|
||||
params := map[string]interface{}{"token": token}
|
||||
// get_node takes obj_type only when the token is an obj_token. For
|
||||
// wiki node_tokens the API rejects an obj_type kwarg, so omit it.
|
||||
if objType != "" && objType != "wiki" {
|
||||
params["obj_type"] = objType
|
||||
}
|
||||
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWikiNodeRecord(common.GetMap(data, "node"))
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
validate.EncodePathSegment(spaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
),
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return "", wrapWikiNodeDeleteAPIError(err)
|
||||
}
|
||||
return common.GetString(data, "task_id"), nil
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiAsyncTaskStatus{}, err
|
||||
}
|
||||
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultSimpleTask)
|
||||
}
|
||||
|
||||
func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec, error) {
|
||||
return parseWikiNodeDeleteSpec(
|
||||
runtime.Str("node-token"),
|
||||
runtime.Str("obj-type"),
|
||||
runtime.Str("space-id"),
|
||||
runtime.Bool("include-children"),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiNodeDeleteSpec normalizes the raw flag values: extracts a token
|
||||
// from a URL when provided, reconciles URL-implied obj_type against the
|
||||
// explicit flag, and validates that the resulting obj_type is one the delete
|
||||
// API accepts.
|
||||
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
|
||||
}
|
||||
|
||||
spec := wikiNodeDeleteSpec{
|
||||
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
|
||||
SpaceID: strings.TrimSpace(rawSpaceID),
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
}
|
||||
spec.NodeToken = token
|
||||
spec.SourceKind = "url"
|
||||
|
||||
// /wiki/<token> implies node_token → obj_type=wiki for the delete API.
|
||||
// Cloud doc paths (/docx/, /sheets/, ...) already give us a concrete type.
|
||||
inferred := urlObjType
|
||||
if inferred == "" {
|
||||
inferred = "wiki"
|
||||
}
|
||||
switch {
|
||||
case spec.ObjType == "":
|
||||
spec.ObjType = inferred
|
||||
case spec.ObjType != inferred:
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, inferred,
|
||||
)
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
} else {
|
||||
spec.NodeToken = tokenInput
|
||||
spec.SourceKind = "raw"
|
||||
}
|
||||
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type is required (one of: %s)",
|
||||
strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
}
|
||||
if !isValidWikiDeleteObjType(spec.ObjType) {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type %q is not valid; pick one of: %s",
|
||||
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func isValidWikiDeleteObjType(v string) bool {
|
||||
for _, t := range wikiNodeDeleteObjTypes {
|
||||
if v == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildWikiNodeDeleteDryRun(spec wikiNodeDeleteSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().Desc(
|
||||
"async-aware: delete wiki node -> poll wiki delete-node task when task_id is returned (auto-resolves space_id via get_node when --space-id is omitted)",
|
||||
)
|
||||
|
||||
if spec.SpaceID == "" {
|
||||
params := map[string]interface{}{"token": spec.NodeToken}
|
||||
if spec.ObjType != "" && spec.ObjType != "wiki" {
|
||||
params["obj_type"] = spec.ObjType
|
||||
}
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve space_id via get_node").
|
||||
Params(params)
|
||||
dry.DELETE(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
"<resolved_space_id>",
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc("[2] Delete wiki node").
|
||||
Body(spec.RequestBody())
|
||||
} else {
|
||||
dry.DELETE(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
validate.EncodePathSegment(spec.SpaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc("[1] Delete wiki node").
|
||||
Body(spec.RequestBody())
|
||||
}
|
||||
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[N] Poll wiki delete-node task result when async").
|
||||
Set("task_id", "<task_id>").
|
||||
Params(map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode})
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
func runWikiNodeDelete(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (map[string]interface{}, error) {
|
||||
spaceID, err := resolveWikiNodeDeleteSpaceID(ctx, client, runtime, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting wiki node %s in space %s (obj_type=%s, include_children=%t)...\n",
|
||||
common.MaskToken(spec.NodeToken), common.MaskToken(spaceID), spec.ObjType, spec.IncludeChildren)
|
||||
|
||||
taskID, err := client.DeleteNode(ctx, spaceID, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"space_id": spaceID,
|
||||
"node_token": spec.NodeToken,
|
||||
"obj_type": spec.ObjType,
|
||||
"include_children": spec.IncludeChildren,
|
||||
}
|
||||
|
||||
// Empty task_id means the delete completed synchronously. Match the
|
||||
// shape used by +delete-space so downstream scripts can read `status`
|
||||
// uniformly regardless of which branch fired.
|
||||
if taskID == "" {
|
||||
out["ready"] = true
|
||||
out["failed"] = false
|
||||
out["status"] = wikiAsyncStatusSuccess
|
||||
out["status_msg"] = wikiAsyncStatusSuccess
|
||||
return out, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki node delete is async, polling task %s...\n", taskID)
|
||||
nextCommand := wikiDeleteNodeTaskResultCommand(taskID, runtime.As())
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
ctx, runtime, taskID, "delete-node",
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval,
|
||||
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
|
||||
return client.GetDeleteNodeTask(ctx, id)
|
||||
},
|
||||
nextCommand,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out["task_id"] = taskID
|
||||
out["ready"] = ready
|
||||
out["failed"] = status.Failed()
|
||||
out["status"] = status.StatusCode()
|
||||
out["status_msg"] = status.StatusLabel()
|
||||
|
||||
if !ready {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-node task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveWikiNodeDeleteSpaceID returns the explicit space_id when the caller
|
||||
// supplied one, otherwise resolves it via get_node. The latter saves callers
|
||||
// from running +node-get first when they only have a node_token.
|
||||
func resolveWikiNodeDeleteSpaceID(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (string, error) {
|
||||
if spec.SpaceID != "" {
|
||||
return spec.SpaceID, nil
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving space_id via get_node for token %s...\n", common.MaskToken(spec.NodeToken))
|
||||
node, err := client.ResolveNode(ctx, spec.NodeToken, spec.ObjType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
spaceID, err := requireWikiNodeSpaceID(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved to space %s\n", common.MaskToken(spaceID))
|
||||
return spaceID, nil
|
||||
}
|
||||
|
||||
func wikiDeleteNodeTaskResultCommand(taskID string, identity core.Identity) string {
|
||||
asFlag := string(identity)
|
||||
if asFlag == "" {
|
||||
asFlag = "user"
|
||||
}
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_delete_node --task-id %s --as %s", taskID, asFlag)
|
||||
}
|
||||
|
||||
// wrapWikiNodeDeleteAPIError attaches actionable hints to the two Lark error
|
||||
// codes whose remediation lives outside the CLI:
|
||||
// - 131011: approval required (deletion gated by Wiki UI approval flow)
|
||||
// - 131003: subtree too large to cascade-delete (must split or use
|
||||
// include_children=false)
|
||||
//
|
||||
// Other codes pass through untouched so the generic error envelope still
|
||||
// surfaces the original code+message.
|
||||
func wrapWikiNodeDeleteAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return err
|
||||
}
|
||||
var hint string
|
||||
switch exitErr.Detail.Code {
|
||||
case wikiDeleteNodeErrCodeApprovalRequired:
|
||||
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
|
||||
case wikiDeleteNodeErrCodeSubtreeTooLarge:
|
||||
hint = "the subtree is too large to cascade-delete in one call; pass --include-children=false to keep the children (they will be moved up to the parent), or delete sub-trees first"
|
||||
}
|
||||
if hint == "" {
|
||||
return err
|
||||
}
|
||||
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
|
||||
hint = existing + "\n" + hint
|
||||
}
|
||||
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
|
||||
// the ExitError by hand so the Lark error code stays available to logs and
|
||||
// downstream pivots.
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
611
shortcuts/wiki/wiki_node_delete_test.go
Normal file
611
shortcuts/wiki/wiki_node_delete_test.go
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ── parseWikiNodeDeleteSpec ─────────────────────────────────────────────────
|
||||
|
||||
func TestParseWikiNodeDeleteSpecAcceptsRawWikiToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "raw" || !spec.IncludeChildren {
|
||||
t.Fatalf("spec = %+v", spec)
|
||||
}
|
||||
body := spec.RequestBody()
|
||||
if body["obj_type"] != "wiki" || body["include_children"] != true {
|
||||
t.Fatalf("RequestBody = %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsMissingObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("wikcnABC", "", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required") {
|
||||
t.Fatalf("expected obj-type required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsInvalidObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("wikcnABC", "comment", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "is not valid") {
|
||||
t.Fatalf("expected invalid obj-type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsEmptyToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec(" ", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "--node-token is required") {
|
||||
t.Fatalf("expected token required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecExtractsTokenFromWikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/wiki/wikcnABC", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "url" {
|
||||
t.Fatalf("spec = %+v, want url-extracted node_token + obj_type=wiki", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecInfersObjTypeFromDocxURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "docxXYZ" || spec.ObjType != "docx" || spec.IncludeChildren {
|
||||
t.Fatalf("spec = %+v, want docxXYZ obj_type=docx include_children=false", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsURLObjTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
|
||||
t.Fatalf("expected obj-type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsPartialPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("/wiki/wikcnABC", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
|
||||
t.Fatalf("expected partial-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildWikiNodeDeleteDryRunWithoutSpaceIDShowsResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeDeleteDryRun(spec)
|
||||
got := decodeDryRunAPIs(t, dry)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(dry.api) = %d, want 3 (get_node, delete, task poll)", len(got))
|
||||
}
|
||||
if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("step[0].URL = %q, want get_node", got[0].URL)
|
||||
}
|
||||
if got[0].Params["obj_type"] != "docx" || got[0].Params["token"] != "docxXYZ" {
|
||||
t.Fatalf("step[0].params = %#v", got[0].Params)
|
||||
}
|
||||
if got[1].URL != "/open-apis/wiki/v2/spaces/<resolved_space_id>/nodes/docxXYZ" {
|
||||
t.Fatalf("step[1].URL = %q, want delete with placeholder", got[1].URL)
|
||||
}
|
||||
if got[1].Body["obj_type"] != "docx" || got[1].Body["include_children"] != true {
|
||||
t.Fatalf("step[1].body = %#v", got[1].Body)
|
||||
}
|
||||
if got[2].Params["task_type"] != "delete_node" {
|
||||
t.Fatalf("step[2].params task_type = %#v, want delete_node", got[2].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWikiNodeDeleteDryRunWithSpaceIDOmitsResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "7629741305993170448", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeDeleteDryRun(spec)
|
||||
got := decodeDryRunAPIs(t, dry)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(dry.api) = %d, want 2 (delete + task poll) when --space-id supplied", len(got))
|
||||
}
|
||||
if got[0].Method != "DELETE" || got[0].URL != "/open-apis/wiki/v2/spaces/7629741305993170448/nodes/wikcnABC" {
|
||||
t.Fatalf("step[0] = %+v", got[0])
|
||||
}
|
||||
if got[0].Body["include_children"] != false {
|
||||
t.Fatalf("body include_children = %#v", got[0].Body["include_children"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── runWikiNodeDelete unit ──────────────────────────────────────────────────
|
||||
|
||||
type fakeWikiNodeDeleteClient struct {
|
||||
resolveErr error
|
||||
resolveNode *wikiNodeRecord
|
||||
resolveCalls []string
|
||||
|
||||
deleteErr error
|
||||
deleteTaskID string
|
||||
deleteCalls []struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeDeleteSpec
|
||||
}
|
||||
|
||||
taskStatuses []wikiAsyncTaskStatus
|
||||
taskErrs []error
|
||||
taskCallArgs []string
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
|
||||
fake.resolveCalls = append(fake.resolveCalls, token)
|
||||
if fake.resolveErr != nil {
|
||||
return nil, fake.resolveErr
|
||||
}
|
||||
return fake.resolveNode, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
fake.deleteCalls = append(fake.deleteCalls, struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeDeleteSpec
|
||||
}{SpaceID: spaceID, Spec: spec})
|
||||
if fake.deleteErr != nil {
|
||||
return "", fake.deleteErr
|
||||
}
|
||||
return fake.deleteTaskID, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
idx := len(fake.taskCallArgs)
|
||||
fake.taskCallArgs = append(fake.taskCallArgs, taskID)
|
||||
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
|
||||
return wikiAsyncTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
|
||||
}
|
||||
if idx < len(fake.taskStatuses) {
|
||||
status := fake.taskStatuses[idx]
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
return wikiAsyncTaskStatus{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
var wikiDeleteNodePollMu sync.Mutex
|
||||
|
||||
func withSingleWikiDeleteNodePoll(t *testing.T) {
|
||||
t.Helper()
|
||||
wikiDeleteNodePollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = prevAttempts, prevInterval
|
||||
wikiDeleteNodePollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func newWikiNodeDeleteRuntime(t *testing.T, as core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +node-delete"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime, stderr
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteResolvesSpaceWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
resolveNode: &wikiNodeRecord{SpaceID: "space_resolved"},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC",
|
||||
ObjType: "wiki",
|
||||
IncludeChildren: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if len(client.resolveCalls) != 1 || client.resolveCalls[0] != "wikcnABC" {
|
||||
t.Fatalf("resolve calls = %v", client.resolveCalls)
|
||||
}
|
||||
if len(client.deleteCalls) != 1 || client.deleteCalls[0].SpaceID != "space_resolved" {
|
||||
t.Fatalf("delete calls = %+v", client.deleteCalls)
|
||||
}
|
||||
if out["space_id"] != "space_resolved" || out["ready"] != true || out["status"] != "success" {
|
||||
t.Fatalf("sync output = %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteSkipsResolveWhenSpaceProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{}
|
||||
|
||||
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_explicit",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if len(client.resolveCalls) != 0 {
|
||||
t.Fatalf("resolveCalls should be empty when --space-id supplied, got %v", client.resolveCalls)
|
||||
}
|
||||
if client.deleteCalls[0].SpaceID != "space_explicit" {
|
||||
t.Fatalf("delete used wrong space: %+v", client.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncReadyShape(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "success"}},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123", IncludeChildren: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if out["task_id"] != "task_async_node" || out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("async-ready output = %#v", out)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "async, polling task") || !strings.Contains(stderr.String(), "delete-node task completed successfully") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncTimeoutReturnsNextCommand(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "processing"}},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
wantNext := wikiDeleteNodeTaskResultCommand("task_async_node", core.AsUser)
|
||||
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wantNext {
|
||||
t.Fatalf("timeout output = %#v", out)
|
||||
}
|
||||
if !strings.Contains(wantNext, "wiki_delete_node") {
|
||||
t.Fatalf("next command should scope wiki_delete_node, got %q", wantNext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "failure", StatusMsg: "permission denied"}},
|
||||
}
|
||||
|
||||
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "delete-node task task_async_node failed: permission denied") {
|
||||
t.Fatalf("expected async failure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── error code hint mapping ─────────────────────────────────────────────────
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeApprovalRequired,
|
||||
Message: "node requires delete approval",
|
||||
},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
|
||||
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
|
||||
}
|
||||
// Original code/message must be preserved so logs and dashboards still
|
||||
// pivot on the upstream error code.
|
||||
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
|
||||
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
|
||||
}
|
||||
if exitErr.Detail.Message != "node requires delete approval" {
|
||||
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
|
||||
Message: "subtree too large",
|
||||
},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
|
||||
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
if !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("unknown code should pass through; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorIgnoresNonExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := errors.New("transport boom")
|
||||
if got := wrapWikiNodeDeleteAPIError(in); got != in {
|
||||
t.Fatalf("non-ExitError should pass through, got %T %v", got, got)
|
||||
}
|
||||
if got := wrapWikiNodeDeleteAPIError(nil); got != nil {
|
||||
t.Fatalf("nil should pass through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mounted execute (httpmock) ──────────────────────────────────────────────
|
||||
|
||||
func TestWikiNodeDeleteExecuteRequiresYesConfirmation(t *testing.T) {
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected high-risk confirmation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteSync(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["ready"] != true || data["failed"] != false || data["space_id"] != "space_123" {
|
||||
t.Fatalf("sync output = %#v", data)
|
||||
}
|
||||
if data["obj_type"] != "wiki" || data["include_children"] != true {
|
||||
t.Fatalf("obj_type/include_children = %#v / %#v", data["obj_type"], data["include_children"])
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(deleteStub.CapturedBody, &captured); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if captured["obj_type"] != "wiki" || captured["include_children"] != true {
|
||||
t.Fatalf("captured DELETE body = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteResolvesSpaceIDFromURL(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
resolveStub := &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{}{
|
||||
"space_id": "space_resolved",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var resolveQuery string
|
||||
resolveStub.OnMatch = func(req *http.Request) { resolveQuery = req.URL.RawQuery }
|
||||
reg.Register(resolveStub)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_resolved/nodes/docxXYZ",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "https://feishu.cn/docx/docxXYZ",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resolveQuery, "token=docxXYZ") || !strings.Contains(resolveQuery, "obj_type=docx") {
|
||||
t.Fatalf("resolve query = %q, want token+obj_type", resolveQuery)
|
||||
}
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["space_id"] != "space_resolved" || data["obj_type"] != "docx" {
|
||||
t.Fatalf("output = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteAsyncSuccess(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_async_node"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_async_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// Gateway returns delete-node status under the generic
|
||||
// simple_task_result key (NOT delete_node_result).
|
||||
"simple_task_result": map[string]interface{}{
|
||||
"status": "success",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["task_id"] != "task_async_node" || data["ready"] != true || data["failed"] != false {
|
||||
t.Fatalf("async-success output = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type dryRunStep struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
func decodeDryRunAPIs(t *testing.T, dry *common.DryRunAPI) []dryRunStep {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []dryRunStep `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
return got.API
|
||||
}
|
||||
370
shortcuts/wiki/wiki_node_get.go
Normal file
370
shortcuts/wiki/wiki_node_get.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the
|
||||
// obj_type the wiki get_node API expects when the token is an obj_token.
|
||||
// /wiki/ is handled separately because node_tokens take no obj_type.
|
||||
//
|
||||
// INVARIANT: the prefixes must be mutually exclusive (no prefix may be a
|
||||
// prefix of another). tokenAndObjTypeFromWikiURL ranges this map, and Go map
|
||||
// iteration order is randomized — overlapping prefixes would make the match
|
||||
// non-deterministic. The trailing slash keeps them disjoint today (e.g.
|
||||
// "/docx/" does not start with "/doc/"); preserve that when adding entries.
|
||||
var wikiNodeGetURLObjTypes = map[string]string{
|
||||
"/docx/": "docx",
|
||||
"/doc/": "doc",
|
||||
"/sheets/": "sheet",
|
||||
"/base/": "bitable",
|
||||
"/mindnote/": "mindnote",
|
||||
"/slides/": "slides",
|
||||
"/file/": "file",
|
||||
}
|
||||
|
||||
// wikiNodeGetObjTypeEnum is the union of obj_types accepted by the upstream
|
||||
// API. It is a superset of the create / move enums so this shortcut can look
|
||||
// up legacy `doc` nodes too.
|
||||
var wikiNodeGetObjTypeEnum = []string{
|
||||
"doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
|
||||
}
|
||||
|
||||
// WikiNodeGet wraps wiki.spaces.get_node so callers can resolve a node by
|
||||
// node_token, obj_token, or a Lark URL without hand-rolling a
|
||||
// `wiki spaces get_node --params ...` invocation. The shortcut prints a
|
||||
// formatted view of the node (title / obj_type / obj_token / parent /
|
||||
// creator / updated_at) and is intended as the "what am I about to
|
||||
// touch?" step before +move / +node-copy / +delete-space.
|
||||
var WikiNodeGet = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-get",
|
||||
Description: "Get wiki node details by node_token, obj_token, or Lark URL",
|
||||
Risk: "read",
|
||||
Scopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true},
|
||||
{Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
|
||||
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiNodeGetSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeGetSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildWikiNodeGetDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiNodeGetSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
|
||||
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := common.GetMap(data, "node")
|
||||
node, err := parseWikiNodeRecord(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
|
||||
return output.ErrValidation(
|
||||
"--space-id %q does not match the resolved node space %q (node_token=%s)",
|
||||
spec.SpaceID, node.SpaceID, node.NodeToken,
|
||||
)
|
||||
}
|
||||
if spec.SpaceID != "" && node.SpaceID == "" {
|
||||
// The cross-check was requested but get_node returned no space_id,
|
||||
// so it silently passed. Surface that the assertion was a no-op
|
||||
// rather than letting the caller assume it was verified.
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"Warning: --space-id %q could not be verified; the resolved node carries no space_id.\n",
|
||||
spec.SpaceID)
|
||||
}
|
||||
|
||||
out := wikiNodeGetOutput(node, raw)
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderWikiNodeGetPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiNodeGetSpec is the normalized input for the shortcut.
|
||||
type wikiNodeGetSpec struct {
|
||||
// Token is the resolved token (after URL extraction) to send to the API.
|
||||
Token string
|
||||
// ObjType is the resolved obj_type. Empty for node_tokens (the API does
|
||||
// not need obj_type for `wik`-prefixed tokens).
|
||||
ObjType string
|
||||
// SpaceID is an optional cross-check; when set, the response space_id must match.
|
||||
SpaceID string
|
||||
// SourceKind records how Token was derived for the dry-run description:
|
||||
// "url-wiki", "url-obj", "raw-node", "raw-obj".
|
||||
SourceKind string
|
||||
}
|
||||
|
||||
// RequestParams returns the query params for GET /wiki/v2/spaces/get_node.
|
||||
func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} {
|
||||
params := map[string]interface{}{"token": spec.Token}
|
||||
if spec.ObjType != "" {
|
||||
params["obj_type"] = spec.ObjType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) {
|
||||
return parseWikiNodeGetSpec(
|
||||
runtime.Str("token"),
|
||||
runtime.Str("obj-type"),
|
||||
runtime.Str("space-id"),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a
|
||||
// URL when needed, picks the obj_type (URL path > explicit flag > none for
|
||||
// node_tokens), and validates the token shape.
|
||||
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--token is required")
|
||||
}
|
||||
|
||||
spec := wikiNodeGetSpec{
|
||||
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
|
||||
SpaceID: strings.TrimSpace(rawSpaceID),
|
||||
}
|
||||
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput)
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
}
|
||||
spec.Token = token
|
||||
if urlObjType == "" {
|
||||
spec.SourceKind = "url-wiki"
|
||||
} else {
|
||||
spec.SourceKind = "url-obj"
|
||||
}
|
||||
switch {
|
||||
case spec.ObjType == "" && urlObjType != "":
|
||||
spec.ObjType = urlObjType
|
||||
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, urlObjType,
|
||||
)
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
spec.SourceKind = "raw-node"
|
||||
// node_tokens take no obj_type; reject a conflicting flag rather
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
// A raw obj_token needs an explicit obj_type: get_node would
|
||||
// otherwise default to "doc" and fail confusingly for docx /
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateOptionalResourceName(spec.Token, "--token"); err != nil {
|
||||
return wikiNodeGetSpec{}, err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
|
||||
return wikiNodeGetSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
|
||||
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
|
||||
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
|
||||
//
|
||||
// This is a heuristic based on the current Lark token-naming convention, not a
|
||||
// guaranteed invariant: if Lark ever introduces a non-node token type that
|
||||
// also starts with `wik`, it would be misclassified. Worst case is a
|
||||
// confusing API error (no data risk); revisit if the token scheme changes.
|
||||
func looksLikeWikiNodeToken(token string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(token), "wik")
|
||||
}
|
||||
|
||||
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
||||
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
||||
// don't need one.
|
||||
func tokenAndObjTypeFromWikiURL(path string) (token, objType string, ok bool) {
|
||||
if t, found := wikiPathSegmentAfter(path, "/wiki/"); found {
|
||||
return t, "", true
|
||||
}
|
||||
for prefix, ot := range wikiNodeGetURLObjTypes {
|
||||
if t, found := wikiPathSegmentAfter(path, prefix); found {
|
||||
return t, ot, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// wikiPathSegmentAfter returns the first path segment after prefix, or ("",
|
||||
// false) when path doesn't start with prefix or the segment is empty.
|
||||
func wikiPathSegmentAfter(path, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func buildWikiNodeGetDryRun(spec wikiNodeGetSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
switch spec.SourceKind {
|
||||
case "url-wiki":
|
||||
dry.Desc("Resolve wiki node from /wiki/ URL")
|
||||
case "url-obj":
|
||||
dry.Desc("Resolve wiki node from Lark document URL (obj_type inferred from path)")
|
||||
case "raw-node":
|
||||
dry.Desc("Look up wiki node by node_token")
|
||||
case "raw-obj":
|
||||
dry.Desc("Look up wiki node by obj_token")
|
||||
}
|
||||
return dry.GET("/open-apis/wiki/v2/spaces/get_node").Params(spec.RequestParams())
|
||||
}
|
||||
|
||||
// wikiNodeGetOutput shapes the structured output. It carries the formatted
|
||||
// values (title/obj_type/obj_token/parent_node_token/creator/updated_at)
|
||||
// the user asked for, plus enough raw fields (node_type, has_child, owner,
|
||||
// timestamps) that callers can pipe into +move / +node-copy without rerunning
|
||||
// get_node.
|
||||
//
|
||||
// No synthesized `url` is emitted: get_node returns none, and a
|
||||
// BuildResourceURL fallback (www.feishu.cn/wiki/<node_token>) is a
|
||||
// non-canonical link that misleads in a read/confirm command. Sibling read
|
||||
// shortcuts (+node-list, +node-copy) likewise omit it; node_token/obj_token
|
||||
// are the precise identifiers.
|
||||
func wikiNodeGetOutput(node *wikiNodeRecord, raw map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"space_id": node.SpaceID,
|
||||
"node_token": node.NodeToken,
|
||||
"obj_token": node.ObjToken,
|
||||
"obj_type": node.ObjType,
|
||||
"node_type": node.NodeType,
|
||||
"parent_node_token": node.ParentNodeToken,
|
||||
"origin_node_token": node.OriginNodeToken,
|
||||
"title": node.Title,
|
||||
"has_child": node.HasChild,
|
||||
}
|
||||
|
||||
creator := strings.TrimSpace(common.GetString(raw, "node_creator"))
|
||||
if creator == "" {
|
||||
creator = strings.TrimSpace(common.GetString(raw, "creator"))
|
||||
}
|
||||
out["creator"] = creator
|
||||
out["owner"] = common.GetString(raw, "owner")
|
||||
|
||||
objEditRaw := common.GetString(raw, "obj_edit_time")
|
||||
out["obj_edit_time"] = objEditRaw
|
||||
out["obj_create_time"] = common.GetString(raw, "obj_create_time")
|
||||
out["node_create_time"] = common.GetString(raw, "node_create_time")
|
||||
out["updated_at"] = formatWikiTimestamp(objEditRaw)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// formatWikiTimestamp turns a Lark unix-seconds string (the format used by
|
||||
// wiki.spaces.get_node) into a UTC RFC3339 string. UTC (not the host's local
|
||||
// zone) keeps the output stable regardless of where the CLI runs. Returns ""
|
||||
// when the input is empty or not numeric so the pretty renderer falls back
|
||||
// to "-".
|
||||
func formatWikiTimestamp(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func renderWikiNodeGetPretty(w io.Writer, out map[string]interface{}) {
|
||||
fmt.Fprintln(w, "Wiki node:")
|
||||
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
|
||||
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
|
||||
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
|
||||
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"]))
|
||||
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"]))
|
||||
fmt.Fprintf(w, " parent_node_token: %s\n", valueOrDash(out["parent_node_token"]))
|
||||
fmt.Fprintf(w, " node_type: %s\n", valueOrDash(out["node_type"]))
|
||||
if origin, _ := out["origin_node_token"].(string); origin != "" {
|
||||
fmt.Fprintf(w, " origin_node_token: %s\n", origin)
|
||||
}
|
||||
hasChild, _ := out["has_child"].(bool)
|
||||
fmt.Fprintf(w, " has_child: %t\n", hasChild)
|
||||
fmt.Fprintf(w, " creator: %s\n", valueOrDash(out["creator"]))
|
||||
if owner, _ := out["owner"].(string); owner != "" {
|
||||
fmt.Fprintf(w, " owner: %s\n", owner)
|
||||
}
|
||||
fmt.Fprintf(w, " updated_at: %s\n", valueOrDash(out["updated_at"]))
|
||||
}
|
||||
321
shortcuts/wiki/wiki_node_get_test.go
Normal file
321
shortcuts/wiki/wiki_node_get_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("wikcnABC", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node wikcnABC with no obj_type", spec)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC"}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: wikcnABC}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("docxXYZ", "docx", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
|
||||
t.Fatalf("spec = %+v, want raw-obj docxXYZ obj_type=docx", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
|
||||
// upfront instead of defaulting to "doc" and hitting an opaque API error.
|
||||
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
|
||||
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
|
||||
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecExtractsTokenFromWikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/wiki/wikcnABC?foo=bar", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "url-wiki" {
|
||||
t.Fatalf("spec = %+v, want url-wiki wikcnABC", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecExtractsTokenAndObjTypeFromDocxURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "url-obj" {
|
||||
t.Fatalf("spec = %+v, want url-obj docxXYZ", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsURLObjTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("https://feishu.cn/sheets/shtXYZ", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
|
||||
t.Fatalf("expected URL/obj-type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") {
|
||||
t.Fatalf("expected unsupported URL path error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("/wiki/wikcnABC", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
|
||||
t.Fatalf("expected partial-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") {
|
||||
t.Fatalf("expected required-token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeGetDryRun(spec)
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("dry-run api = %#v, want single get_node call", got.API)
|
||||
}
|
||||
if got.API[0].Params["token"] != "docxXYZ" || got.API[0].Params["obj_type"] != "docx" {
|
||||
t.Fatalf("dry-run params = %#v", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWikiTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := formatWikiTimestamp(""); got != "" {
|
||||
t.Fatalf("formatWikiTimestamp(empty) = %q, want empty", got)
|
||||
}
|
||||
if got := formatWikiTimestamp("not-a-number"); got != "" {
|
||||
t.Fatalf("formatWikiTimestamp(non-numeric) = %q, want empty", got)
|
||||
}
|
||||
// Output is UTC, so it is deterministic regardless of host timezone.
|
||||
if got := formatWikiTimestamp("1700000000"); got != "2023-11-14T22:13:20Z" {
|
||||
t.Fatalf("formatWikiTimestamp(1700000000) = %q, want 2023-11-14T22:13:20Z (UTC)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
stub := &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{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "wikcnPARENT",
|
||||
"node_type": "origin",
|
||||
"title": "Design Spec",
|
||||
"has_child": true,
|
||||
"node_creator": "ou_creator",
|
||||
"owner": "ou_owner",
|
||||
"obj_edit_time": "1700000000",
|
||||
"obj_create_time": "1690000000",
|
||||
"node_create_time": "1690000001",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
var capturedQuery string
|
||||
stub.OnMatch = func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "https://feishu.cn/docx/docxXYZ",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
|
||||
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["title"] != "Design Spec" {
|
||||
t.Fatalf("title = %#v, want Design Spec", data["title"])
|
||||
}
|
||||
if data["obj_type"] != "docx" || data["obj_token"] != "docxXYZ" {
|
||||
t.Fatalf("obj_type/obj_token = %#v / %#v", data["obj_type"], data["obj_token"])
|
||||
}
|
||||
if data["parent_node_token"] != "wikcnPARENT" {
|
||||
t.Fatalf("parent_node_token = %#v", data["parent_node_token"])
|
||||
}
|
||||
if data["creator"] != "ou_creator" {
|
||||
t.Fatalf("creator = %#v, want ou_creator", data["creator"])
|
||||
}
|
||||
if data["owner"] != "ou_owner" {
|
||||
t.Fatalf("owner = %#v, want ou_owner", data["owner"])
|
||||
}
|
||||
if got, _ := data["updated_at"].(string); got != "2023-11-14T22:13:20Z" {
|
||||
t.Fatalf("updated_at = %#v, want 2023-11-14T22:13:20Z (UTC)", data["updated_at"])
|
||||
}
|
||||
// +node-get deliberately does not synthesize a url (get_node returns none;
|
||||
// a BuildResourceURL fallback would be a non-canonical, misleading link in
|
||||
// a read/confirm command).
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("did not expect a url field in +node-get output, got %#v", data["url"])
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "Fetching wiki node") {
|
||||
t.Fatalf("stderr = %q, want fetching message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
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{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Fallback Creator",
|
||||
"creator": "ou_legacy_creator",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "wikcnABC",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["creator"] != "ou_legacy_creator" {
|
||||
t.Fatalf("creator = %#v, want fallback to creator field", data["creator"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
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{}{
|
||||
"space_id": "space_actual",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Mismatch",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "wikcnABC",
|
||||
"--space-id", "space_expected",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the resolved node space") {
|
||||
t.Fatalf("expected space mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
120
shortcuts/wiki/wiki_space_create.go
Normal file
120
shortcuts/wiki/wiki_space_create.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// WikiSpaceCreate wraps wiki.spaces.create. The raw API only takes two
|
||||
// optional string fields, so the shortcut's value is flag ergonomics
|
||||
// (no hand-written --params JSON), output flattening (data.space.* lifted
|
||||
// to the top level), and a dry-run preview.
|
||||
//
|
||||
// The API only accepts a user access token (no tenant/bot), so AuthTypes is
|
||||
// user-only — the framework's CheckIdentity rejects --as bot for us.
|
||||
var WikiSpaceCreate = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+space-create",
|
||||
Description: "Create a wiki space",
|
||||
Risk: "write",
|
||||
// The API accepts wiki:wiki or wiki:space:write_only. The framework's
|
||||
// scope preflight does exact-string matching (see +space-list), so
|
||||
// declare the narrowest form the API takes to avoid false-rejecting
|
||||
// tokens that only carry wiki:space:write_only.
|
||||
Scopes: []string{"wiki:space:write_only"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "wiki space name", Required: true},
|
||||
{Name: "description", Desc: "wiki space description"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only --as user is supported; the create API does not accept a tenant/bot token.",
|
||||
"The underlying spaces.create API is flagged danger in the schema browser; a space is recoverable via `wiki +delete-space` if created by mistake.",
|
||||
"--name is required: an unnamed space is almost always an accident and is hard to find later.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiSpaceCreateSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiSpaceCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(wikiSpacesAPIPath).
|
||||
Body(spec.RequestBody())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiSpaceCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
|
||||
|
||||
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := common.GetMap(data, "space")
|
||||
if raw == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
|
||||
}
|
||||
|
||||
out := wikiSpaceCreateOutput(raw)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki space %s\n", common.MaskToken(common.GetString(out, "space_id")))
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiSpaceCreateSpec is the normalized CLI input.
|
||||
type wikiSpaceCreateSpec struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// RequestBody converts the normalized input into the OpenAPI payload. Both
|
||||
// fields are optional per the API, but Validate enforces a non-empty name,
|
||||
// so name is always present here.
|
||||
func (spec wikiSpaceCreateSpec) RequestBody() map[string]interface{} {
|
||||
body := map[string]interface{}{"name": spec.Name}
|
||||
if spec.Description != "" {
|
||||
body["description"] = spec.Description
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpec, error) {
|
||||
spec := wikiSpaceCreateSpec{
|
||||
Name: strings.TrimSpace(runtime.Str("name")),
|
||||
Description: strings.TrimSpace(runtime.Str("description")),
|
||||
}
|
||||
if spec.Name == "" {
|
||||
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// wikiSpaceCreateOutput flattens data.space into the top-level envelope. It
|
||||
// reads the raw map (rather than parseWikiSpaceRecord) so the description
|
||||
// the caller just set round-trips back in the output.
|
||||
func wikiSpaceCreateOutput(raw map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(raw, "space_id"),
|
||||
"name": common.GetString(raw, "name"),
|
||||
"description": common.GetString(raw, "description"),
|
||||
"space_type": common.GetString(raw, "space_type"),
|
||||
"visibility": common.GetString(raw, "visibility"),
|
||||
"open_sharing": common.GetString(raw, "open_sharing"),
|
||||
}
|
||||
}
|
||||
207
shortcuts/wiki/wiki_space_create_test.go
Normal file
207
shortcuts/wiki/wiki_space_create_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWikiSpaceCreateDeclaredContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if WikiSpaceCreate.Command != "+space-create" {
|
||||
t.Fatalf("Command = %q, want +space-create", WikiSpaceCreate.Command)
|
||||
}
|
||||
if WikiSpaceCreate.Risk != "write" {
|
||||
t.Fatalf("Risk = %q, want write", WikiSpaceCreate.Risk)
|
||||
}
|
||||
if !reflect.DeepEqual(WikiSpaceCreate.AuthTypes, []string{"user"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user]", WikiSpaceCreate.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(WikiSpaceCreate.Scopes, []string{"wiki:space:write_only"}) {
|
||||
t.Fatalf("Scopes = %v, want [wiki:space:write_only]", WikiSpaceCreate.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWikiSpaceCreateSpecRejectsBlankName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", " ", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
if _, err := readWikiSpaceCreateSpec(runtime); err == nil || !strings.Contains(err.Error(), "--name is required") {
|
||||
t.Fatalf("expected blank-name rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nameOnly := wikiSpaceCreateSpec{Name: "Eng Wiki"}.RequestBody()
|
||||
if !reflect.DeepEqual(nameOnly, map[string]interface{}{"name": "Eng Wiki"}) {
|
||||
t.Fatalf("name-only body = %#v", nameOnly)
|
||||
}
|
||||
|
||||
full := wikiSpaceCreateSpec{Name: "Eng Wiki", Description: "team docs"}.RequestBody()
|
||||
if full["name"] != "Eng Wiki" || full["description"] != "team docs" {
|
||||
t.Fatalf("full body = %#v", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", "Eng Wiki", "")
|
||||
cmd.Flags().String("description", "team docs", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
|
||||
dry := WikiSpaceCreate.DryRun(nil, runtime)
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/wiki/v2/spaces" {
|
||||
t.Fatalf("dry-run api = %#v", got.API)
|
||||
}
|
||||
if got.API[0].Body["name"] != "Eng Wiki" || got.API[0].Body["description"] != "team docs" {
|
||||
t.Fatalf("dry-run body = %#v", got.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateDryRunBlankNameSurfacesError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
|
||||
dry := WikiSpaceCreate.DryRun(nil, runtime)
|
||||
data, _ := json.Marshal(dry)
|
||||
if !strings.Contains(string(data), "--name is required") {
|
||||
t.Fatalf("dry-run should surface validation error, got %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateMountedExecuteFlattensSpace(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "7160145948494381236",
|
||||
"name": "Eng Wiki",
|
||||
"description": "team docs",
|
||||
"space_type": "team",
|
||||
"visibility": "private",
|
||||
"open_sharing": "closed",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--description", "team docs",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["space_id"] != "7160145948494381236" {
|
||||
t.Fatalf("space_id = %#v", data["space_id"])
|
||||
}
|
||||
if data["name"] != "Eng Wiki" || data["description"] != "team docs" {
|
||||
t.Fatalf("name/description = %#v / %#v", data["name"], data["description"])
|
||||
}
|
||||
if data["space_type"] != "team" || data["visibility"] != "private" || data["open_sharing"] != "closed" {
|
||||
t.Fatalf("space_type/visibility/open_sharing = %#v", data)
|
||||
}
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("output must not include a url field, got %#v", data["url"])
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if captured["name"] != "Eng Wiki" || captured["description"] != "team docs" {
|
||||
t.Fatalf("captured request body = %#v", captured)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Created wiki space") {
|
||||
t.Fatalf("stderr = %q, want creation log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateRejectsBotIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supports: user") {
|
||||
t.Fatalf("expected bot identity rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateErrorsWhenNoSpaceReturned(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "returned no space") {
|
||||
t.Fatalf("expected missing-space error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces"
|
||||
wikiSpacesAPIPath = "/open-apis/wiki/v2/spaces"
|
||||
wikiSpaceListDefaultPageSize = 50
|
||||
wikiSpaceListMaxPageSize = 50
|
||||
)
|
||||
@@ -59,7 +59,7 @@ var WikiSpaceList = common.Shortcut{
|
||||
if wikiListShouldAutoPaginate(runtime) {
|
||||
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||
}
|
||||
return dry.GET(wikiSpaceListAPIPath).Params(params)
|
||||
return dry.GET(wikiSpacesAPIPath).Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
@@ -103,7 +103,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil)
|
||||
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
|
||||
#### 处理流程
|
||||
|
||||
**推荐方式:使用 `drive +inspect` 自动解包**
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
```
|
||||
|
||||
返回结果包含 `type`(底层文档类型)、`token`(真实 file_token)、`title`、`url` 等字段,直接用于后续操作。
|
||||
|
||||
**手动方式:使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
|
||||
@@ -107,8 +107,9 @@ metadata:
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
| `+record-upload-attachment` | 给已有记录上传一个或多个附件 | 看 `lark-cli base +record-upload-attachment --help` | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值;不支持 `--name` |
|
||||
| `+record-download-attachment` | 下载一个或多个 Base 附件到本地 | 看 `lark-cli base +record-download-attachment --help` | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| `+record-remove-attachment` | 删除附件字段里的一个或多个附件 | 看 `lark-cli base +record-remove-attachment --help` | 删除操作;确认目标后带 `--yes` |
|
||||
| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
|
||||
@@ -211,7 +212,7 @@ metadata:
|
||||
| 字段类型 | 含义 | 能否直接作为 `+record-upsert / +record-batch-create / +record-batch-update` 写入目标 | 说明 |
|
||||
|----------|------|-----------------------------------------------------------|------|
|
||||
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `lark-cli docs +media-download` |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
|
||||
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
|
||||
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
|
||||
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
|
||||
@@ -225,7 +226,7 @@ metadata:
|
||||
| 用户明确要求 lookup,或天然是固定查找配置 | `lookup` 字段 | 不要默认先上 lookup;先判断 formula 是否更合适 |
|
||||
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
|
||||
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| 下载记录里的附件文件 | `lark-cli docs +media-download --token <file_token> --output <path>` | `file_token` 从 `+record-get` 返回的附件字段里取;用法见 [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) |
|
||||
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
|
||||
### 2.9 attachment(不作为普通 CellValue 写入)
|
||||
|
||||
用户要把本地文件加到记录里时,必须使用 `lark-cli base +record-upload-attachment --file <path>` 上传到已有记录。不能用普通记录操作接口来上传附件。
|
||||
|
||||
`+record-get` 返回的附件字段单元格包含 `file_token` 和文件名,可以把 `file_token` 交给 `lark-cli docs +media-download` 进行附件下载。
|
||||
- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id <record_id> --field-id <field_id> --file <path>`;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口写附件值。
|
||||
- 删除附件:使用 `lark-cli base +record-remove-attachment --record-id <record_id> --field-id <field_id> --file-token <file_token> --yes`;可重复 `--file-token` 一次删除同一单元格里的多个附件。
|
||||
- 下载附件:使用 `lark-cli base +record-download-attachment --record-id <record_id> --file-token <file_token> --output <dir>`;不传 `--file-token` 时下载整行所有附件,也可重复 `--file-token` 只下载指定附件。Base 附件必须用这个命令下载,用其他下载入口可能失败。
|
||||
|
||||
## 3. 只读字段(不要写)
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# base +record-upload-attachment
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
上传本地文件到当前 Base,并把附件值写入指定记录的附件字段。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-upload-attachment \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--record-id <record_id> \
|
||||
--field-id <field_id> \
|
||||
--file ./report.pdf
|
||||
|
||||
lark-cli base +record-upload-attachment \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--record-id <record_id> \
|
||||
--field-id "附件" \
|
||||
--file ./report.pdf \
|
||||
--name "Q1-final.pdf"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--record-id <id>` | 是 | 记录 ID |
|
||||
| `--field-id <id_or_name>` | 是 | 附件字段 ID 或字段名 |
|
||||
| `--file <path>` | 是 | 本地文件路径,最大 2GB |
|
||||
| `--name <name>` | 否 | 写入附件字段时显示的文件名,默认使用本地文件名 |
|
||||
|
||||
|
||||
## 工作流
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是写入操作。用户已经明确要上传到某条记录的某个附件字段时可直接执行;如果 `record-id` 或目标字段仍有歧义,再先确认。
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 目标字段必须是 `attachment` 字段。
|
||||
- ⚠️ 记录里的附件 `file_token` 属于 Drive media token;下载时不要走 `lark-cli drive +download`,应使用 `lark-cli docs +media-download --token <file_token> --output <path>`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
@@ -12,8 +12,9 @@ record 相关命令索引。
|
||||
| [lark-base-record-upsert.md](lark-base-record-upsert.md) | `+record-upsert` | 创建或更新记录 |
|
||||
| [lark-base-record-batch-create.md](lark-base-record-batch-create.md) | `+record-batch-create` | 按 `fields/rows` 批量创建记录 |
|
||||
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
|
||||
| [lark-base-record-upload-attachment.md](lark-base-record-upload-attachment.md) | `+record-upload-attachment` | 上传本地文件到附件字段并更新记录 |
|
||||
| [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md) | `lark-cli docs +media-download` | 下载 Base 附件到本地(附件的 `file_token` 来自 `+record-get` 的附件字段) |
|
||||
| `--help` | `+record-upload-attachment` | 上传一个或多个本地文件到附件字段 |
|
||||
| `--help` | `+record-download-attachment` | 下载一个或多个 Base 附件到本地;Base 附件必须用这个命令下载 |
|
||||
| `--help` | `+record-remove-attachment` | 删除附件字段中的一个或多个附件 |
|
||||
| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除一条或多条记录 |
|
||||
| [lark-base-record-share-link-create.md](lark-base-record-share-link-create.md) | `+record-share-link-create` | 生成记录分享链接(支持单条或批量,最多 100 条)|
|
||||
|
||||
@@ -25,5 +26,6 @@ record 相关命令索引。
|
||||
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
|
||||
- `+record-get` 支持重复 `--record-id` 或 `--json '{"record_id_list":[...]}'` 批量读取;也支持重复传参 `--field-id` 裁剪返回字段,避免返回全字段。
|
||||
- 写记录 JSON 前优先阅读 [lark-base-cell-value.md](lark-base-cell-value.md)。
|
||||
- 本地文件写入附件字段时,必须使用 `+record-upload-attachment`。
|
||||
- 从附件字段下载文件时,用 `lark-cli docs +media-download --token <file_token> --output <path>`,用法见 [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md)。
|
||||
- 本地文件写入一个或多个附件字段时,必须使用 `+record-upload-attachment`。
|
||||
- 从附件字段下载一个或多个文件时,用 `+record-download-attachment`。
|
||||
- 删除附件字段里的文件时,用 `+record-remove-attachment --yes`。
|
||||
|
||||
@@ -19,7 +19,7 @@ metadata:
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
@@ -51,6 +51,16 @@ metadata:
|
||||
|
||||
#### 处理流程
|
||||
|
||||
**推荐方式:使用 `drive +inspect` 自动解包**
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
```
|
||||
|
||||
返回结果包含 `type`(底层文档类型)、`token`(真实 file_token)、`title`、`url` 等字段,直接用于后续操作。
|
||||
|
||||
**手动方式:使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
@@ -245,6 +255,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
|
||||
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
|
||||
@@ -258,6 +269,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -150,8 +150,6 @@ lark-cli drive +add-comment \
|
||||
- `type=text` 的评论文本不能直接包含 `<`、`>`;应优先传 `<`、`>`。shortcut 在发送前也会自动将 `<`、`>` 转义为 `<`、`>` 作为兜底。
|
||||
- **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。
|
||||
- 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。
|
||||
- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`。
|
||||
- 当 `locate-doc` 命中多处时,shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。
|
||||
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
|
||||
- 统一接口:`POST /new_comments`
|
||||
- 统一字段:`file_type` + `reply_elements`
|
||||
|
||||
@@ -25,8 +25,8 @@ lark-cli drive +export \
|
||||
--doc-type doc \
|
||||
--file-extension docx
|
||||
|
||||
# 导出 docx 为 markdown
|
||||
# 注意:markdown 只支持 docx,底层走 /open-apis/docs/v1/content
|
||||
# 导出 docx 为 markdown(Lark-flavored Markdown)
|
||||
# 注意:markdown 只支持 docx
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
|
||||
50
skills/lark-drive/references/lark-drive-inspect.md
Normal file
50
skills/lark-drive/references/lark-drive-inspect.md
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
# drive +inspect(文档 URL 检视:类型、标题、Token 解析)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
给定一个飞书文档 URL 或 bare token,返回其类型、标题和 canonical token。对 wiki URL 自动解包到底层文档。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 检视一个 docx URL
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/docx/doxcnXXX'
|
||||
|
||||
# 检视一个 wiki URL(自动解包到底层文档)
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
|
||||
# bare token 需要指定 --type
|
||||
lark-cli drive +inspect --url doxcnXXX --type docx
|
||||
|
||||
# 格式化输出
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/base/bascnXXX' --format pretty
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出包含以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `input_url` | 原始输入 URL |
|
||||
| `type` | 文档类型(docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides) |
|
||||
| `title` | 文档标题 |
|
||||
| `token` | canonical file token |
|
||||
| `url` | 重建的 canonical URL |
|
||||
| `wiki_node` | 仅 wiki URL:包含 `space_id`, `node_token`, `obj_token`, `obj_type` |
|
||||
|
||||
## 典型场景
|
||||
|
||||
| 场景 | 命令 |
|
||||
|------|------|
|
||||
| 用户给了一个 URL,想知道它是什么类型的文档 | `lark-cli drive +inspect --url '<url>'` |
|
||||
| wiki 链接需要拿到底层文档的 token 来做后续操作 | `lark-cli drive +inspect --url '<wiki_url>'`,取输出中的 `token` |
|
||||
| 只有 token 没有 URL | `lark-cli drive +inspect --url <token> --type <type>` |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `--url` 为必填参数
|
||||
- 当 `--url` 是 bare token(非完整 URL)时,`--type` 也是必填的
|
||||
- wiki URL 会自动调用 `get_node` API 解包,输出中 `type` 和 `token` 是底层文档的类型和 token
|
||||
- 支持 `--dry-run` 查看将调用的 API 步骤
|
||||
@@ -6,7 +6,7 @@
|
||||
上传本地文件到飞书云空间。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。
|
||||
|
||||
## 快速决策
|
||||
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -135,6 +135,12 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.
|
||||
5. **For full content:** table output truncates content. Use `--format json` when you need the complete message body.
|
||||
6. **For sender info:** the command already resolves sender names, so you do not need a separate lookup.
|
||||
7. **Application/bot identity + named group history:** If the user says "使用应用身份/以 bot 身份" and asks to list or read historical messages for a named group, use bot identity for both steps:
|
||||
```bash
|
||||
lark-cli im +chat-search --as bot --query "<chat name keyword>" --format json
|
||||
lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --page-size 50 --format json
|
||||
```
|
||||
Do not use `im +messages-search --as bot`; `+messages-search` is user-only. Continue with `--page-token` if `has_more=true`.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -156,6 +156,21 @@ Use `im +messages-resources-download` if you need to fetch the underlying image
|
||||
|
||||
## AI Usage Guidance
|
||||
|
||||
### Query boundary for activity review
|
||||
|
||||
Use `--query` only for real message keywords. If the user asks for activity review such as "最近一周我和哪些 Bot 有过交互" or "整理我和某人的聊天记录", and the useful constraints are sender type, chat, person, or time range, keep `--query ""` and rely on those filters. Do not put generic instruction words such as "看看", "总结", "交互内容", or "聊天记录" into `--query`; those words often over-constrain message search and hide the relevant messages.
|
||||
|
||||
This guidance applies only when using user identity. `im +messages-search` is user-only; if the user explicitly asks for application/bot identity, do not try `--as bot`. For bot identity with a named group and history/listing intent, resolve the group with `im +chat-search --as bot`, then list messages with `im +chat-messages-list --as bot --chat-id <chat_id>`.
|
||||
|
||||
```bash
|
||||
# Review recent bot interactions without forcing a keyword
|
||||
lark-cli im +messages-search --query "" --sender-type bot --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --page-all --format json
|
||||
```
|
||||
|
||||
Replace the time placeholders at execution time. For example, "最近一周" means computing the start date and end date from the current day before running the command; do not copy date literals from this reference into answers for relative requests.
|
||||
|
||||
For activity summaries, validate evidence by message IDs and chat context. The final answer should cite or retain the `message_id`, sender, chat, and create time for each important item. If the row's source data contains concrete `om_...` message IDs or `ou_...` user IDs, treat those IDs as strong recall targets during verification; do not rely only on a high-level keyword match.
|
||||
|
||||
### Resolving chat_id from a chat name
|
||||
|
||||
When the user refers to a chat by name and you need its `chat_id` for the `--chat-id` filter, use [`+chat-search`](lark-im-chat-search.md) first:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-markdown
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
description: "飞书 Markdown:查看、创建、上传和编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取或修改时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -16,6 +16,7 @@ metadata:
|
||||
|
||||
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
|
||||
- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch`
|
||||
- 用户要对 Markdown 文件做**局部文本替换 / 正则替换**,优先使用 `lark-cli markdown +patch`
|
||||
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
@@ -28,6 +29,10 @@ metadata:
|
||||
- 直接传字符串
|
||||
- `@file` 从本地文件读取内容
|
||||
- `-` 从 stdin 读取内容
|
||||
- `markdown +patch` 的内部语义是:**先完整下载 Markdown,再本地替换,再整文件覆盖上传**
|
||||
- `markdown +patch` 不是服务端原子 patch;它是 CLI 侧编排出来的局部更新能力
|
||||
- `markdown +patch` 当前只支持**单组** `--pattern` / `--content`
|
||||
- `markdown +patch` 替换后的最终内容**不能为空**;如果替换后整篇 Markdown 变成空字符串,CLI 会直接报错,不会上传空文件
|
||||
- `--file` 只接受本地 `.md` 文件路径
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
@@ -38,6 +43,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |
|
||||
| [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive |
|
||||
| [`+patch`](references/lark-markdown-patch.md) | Patch a Markdown file in Drive via fetch-local-replace-overwrite |
|
||||
| [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive |
|
||||
|
||||
## 参考
|
||||
|
||||
135
skills/lark-markdown/references/lark-markdown-patch.md
Normal file
135
skills/lark-markdown/references/lark-markdown-patch.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# markdown +patch
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
对 Drive 中已有的原生 Markdown 文件做局部文本替换,并返回是否实际写入了新版本。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 字面量替换
|
||||
lark-cli markdown +patch \
|
||||
--file-token boxcnxxxx \
|
||||
--pattern 'hello markdown' \
|
||||
--content 'hello patched'
|
||||
|
||||
# 正则替换(RE2)
|
||||
lark-cli markdown +patch \
|
||||
--file-token boxcnxxxx \
|
||||
--regex \
|
||||
--pattern 'hello (.+)' \
|
||||
--content 'hi $1'
|
||||
|
||||
# 删除匹配内容
|
||||
lark-cli markdown +patch \
|
||||
--file-token boxcnxxxx \
|
||||
--pattern ' debug' \
|
||||
--content ''
|
||||
|
||||
# --pattern / --content 也支持 @file
|
||||
lark-cli markdown +patch \
|
||||
--file-token boxcnxxxx \
|
||||
--pattern @./pattern.txt \
|
||||
--content @./replacement.md
|
||||
|
||||
# 从 stdin 读取 replacement
|
||||
printf 'hi patched\n' | \
|
||||
lark-cli markdown +patch \
|
||||
--file-token boxcnxxxx \
|
||||
--pattern 'hello markdown' \
|
||||
--content -
|
||||
|
||||
# 预览底层编排
|
||||
lark-cli markdown +patch \
|
||||
--file-token boxcnxxxx \
|
||||
--pattern 'hello markdown' \
|
||||
--content 'hello patched' \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 目标 Markdown 文件 token |
|
||||
| `--pattern` | 是 | 要匹配的文本;默认按字面量处理;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--content` | 是 | 替换后的内容;支持直接传字符串、`@file`、`-`(stdin);允许空字符串 `''`,表示删除匹配内容 |
|
||||
| `--regex` | 否 | 将 `--pattern` 按 Go RE2 正则解释;`--content` 支持 `$1` 这类分组替换;如果需要字面 `$`,请写成 `$$` |
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 当前只支持**单组** `--pattern` / `--content`
|
||||
- `--pattern` 必须显式传入且不能为空字符串
|
||||
- `--content` 必须显式传入,但允许为空字符串
|
||||
- 未加 `--regex` 时,行为等价于对整份 Markdown 文本执行 `strings.ReplaceAll`
|
||||
- 加了 `--regex` 时,行为等价于对整份 Markdown 文本执行 RE2 全量替换;`--content` 里的 `$1`、`${name}` 会按 Go regexp replacement template 解释,字面 `$` 请写成 `$$`
|
||||
- 替换后的最终 Markdown 不能为空;如果 patch 结果是空字符串,CLI 会直接报错,不会上传空文件
|
||||
- `0` 命中时命令仍然成功返回,但不会上传新版本
|
||||
|
||||
## 实现边界
|
||||
|
||||
- 该命令的内部语义是:**download -> local replace -> overwrite upload**
|
||||
- 它不是服务端原子 patch;如果有人在你下载后、上传前更新了同一文件,本次 patch 仍可能覆盖那次中间修改
|
||||
- 它不会返回详细匹配位置,只返回命中数量
|
||||
- `--dry-run` 会同时展示两种可能的上传路径:`upload_all`(小文件)和 `upload_prepare/upload_part/upload_finish`(大文件分片上传)
|
||||
|
||||
## 返回值
|
||||
|
||||
命中并写入新版本:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"updated": true,
|
||||
"mode": "literal",
|
||||
"match_count": 1,
|
||||
"version": "7639217385152646325",
|
||||
"size_bytes_before": 39,
|
||||
"size_bytes_after": 41
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
未命中:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"updated": false,
|
||||
"mode": "literal",
|
||||
"match_count": 0,
|
||||
"version": "",
|
||||
"size_bytes_before": 41,
|
||||
"size_bytes_after": 41
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `updated` 表示本次是否真的上传了新版本
|
||||
- `mode` 为 `literal` 或 `regex`
|
||||
- `match_count` 是匹配次数
|
||||
- `version` 只有在 `updated=true` 时才会有值
|
||||
- `size_bytes_before` / `size_bytes_after` 分别是替换前后的 Markdown 大小
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 只需要替换一小段 Markdown 文本,而不想自己手动 `fetch -> edit -> overwrite`
|
||||
- 需要基于正则做简单批量替换
|
||||
- 需要判断“这次是否真的改到了内容”
|
||||
|
||||
## 不适用场景
|
||||
|
||||
- 需要 rename / move / delete / permission / comment 管理:切到 [`lark-drive`](../../lark-drive/SKILL.md)
|
||||
- 需要多组 patch 一次完成:当前不支持,改为多次调用 `markdown +patch`
|
||||
- 需要真正原子更新:当前能力不提供
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -10,16 +10,38 @@ metadata:
|
||||
|
||||
# slides (v1)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。生成本地 XML 后,如可运行 Python,MUST 先用 [`scripts/layout_lint.py`](scripts/layout_lint.py) 检查 XML well-formed、重叠/越界/文本高度风险,再创建或追加页面。它不是完整 XSD schema 校验。**
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
@@ -41,55 +63,69 @@ lark-cli auth login --domain slides
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
一条命令创建包含页面内容的 PPT(推荐):
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --title "演示文稿标题" --slides '[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245,245,245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"100\"><content textType=\"title\"><p>页面标题</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"200\"><content textType=\"body\"><p>正文内容</p><ul><li><p>要点一</p></li><li><p>要点二</p></li></ul></content></shape></data></slide>"
|
||||
]'
|
||||
```
|
||||
|
||||
也可以分两步(先创建空白 PPT,再逐页添加),详见 [+create 参考文档](references/lark-slides-create.md)。
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 适合简单页面批量创建,但并不等同于“10 页以内都安全”。如果 slide XML 含中文、大段文本、复杂布局、嵌套引号或较多特殊字符,shell 传参时可能出现转义或截断问题,导致内容丢失、页面空白或布局异常。遇到复杂页面时,优先改用“两步创建法”。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层是“先创建空白 PPT,再逐页调用 `xml_presentation.slide.create`”。这不是原子操作;中途某一页失败时,前面已创建成功的页面会保留。skill 必须把这种“部分成功”风险提前告诉用户,并在失败后先记录 `xml_presentation_id`,回读确认当前状态,再决定是否在现有 PPT 上继续修复或追加。
|
||||
|
||||
> 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。
|
||||
|
||||
## 执行前必做
|
||||
|
||||
> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
|
||||
|
||||
### 必读(每次创建前)
|
||||
高频只读:
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML 元素和属性速查,必读** |
|
||||
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
|
||||
- [planning-layer.md](references/planning-layer.md)(新建 / 大幅改写)
|
||||
- [visual-planning.md](references/visual-planning.md)(新建 / 大幅改写)
|
||||
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
### 选读(需要时查阅)
|
||||
按需再读:
|
||||
|
||||
| 场景 | 文档 |
|
||||
|------|------|
|
||||
| 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) |
|
||||
| 需要快速筛模板、做低成本路由 | [`scripts/template_tool.py search`](scripts/template_tool.py) |
|
||||
| 需要匹配 PPT 模板/主题风格 | [template-catalog.md](references/template-catalog.md) |
|
||||
| 需要按页型抽摘要或裁切 XML 片段 | [`scripts/template_tool.py`](scripts/template_tool.py) |
|
||||
| 需要做本地布局风险检查 | [`scripts/layout_lint.py`](scripts/layout_lint.py) |
|
||||
| 需要 CLI 调用示例 | [examples.md](references/examples.md) |
|
||||
| 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) |
|
||||
| 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema) |
|
||||
| 需要编辑已有 PPT 的单个页面 | [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) |
|
||||
| 需要了解某个命令的详细参数 | 对应命令的 reference 文档(见下方参考文档章节) |
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
|
||||
|
||||
开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
|
||||
|
||||
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
|
||||
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重,1-2 个辅助色承担结构和分区,1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
- **双栏结构**:左文右图或左图右文,视觉区域占 35-45% 宽度。
|
||||
- **图标行**:图标在色块或圆形底中,右侧是短标题和一句解释。
|
||||
- **2x2 / 2x3 网格**:适合能力、模块、风险、行动项,每格内容保持同等层级。
|
||||
- **半出血视觉**:图片或抽象形状占据左/右半屏,文字覆盖或贴边排布。
|
||||
- **大数字卡片**:关键指标用 60-72pt 数字,下面配 10-14pt 标签。
|
||||
- **对比列**:before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。
|
||||
- **时间线/流程图**:步骤用节点和箭头表达,流程方向必须一眼可见。
|
||||
|
||||
字体和间距建议:
|
||||
|
||||
- 标题 36-44pt,关键结论可更大;正文 14-18pt;注释 10-12pt。
|
||||
- 正文默认左对齐;只在封面、结尾或大号数字场景中使用居中。
|
||||
- 页面边距至少 40px;内容块之间保持 24-40px 间距,并在同一 deck 内保持一致。
|
||||
- 卡片内边距要真实留出空间,不要让文字贴边;对齐 shape 和文字时要考虑文本框 padding。
|
||||
|
||||
常见错误必须避免:
|
||||
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
@@ -101,156 +137,40 @@ lark-cli slides +create --title "演示文稿标题" --slides '[
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 模板与脚本优先流程
|
||||
|
||||
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
|
||||
```bash
|
||||
# 1. 搜索候选:把用户原始需求整句放进 --query,不要只放手动提炼的短词
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
|
||||
# 2. 锁定模板后先看页型摘要
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
|
||||
|
||||
# 3. 只有需要复用布局骨架时才裁切 XML
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
|
||||
|
||||
# 4. 生成待创建 XML 后先做布局风险检查
|
||||
python3 skills/lark-slides/scripts/layout_lint.py --input /tmp/presentation.xml
|
||||
```
|
||||
|
||||
执行规则:
|
||||
|
||||
1. `search --query` 使用用户原始描述;如用户明确风格,再额外加 `--tone light|dark|colorful` 或 `--formality formal|casual|creative`。
|
||||
2. 候选展示只给 2-3 个,包含模板名、适用场景、风格/色调、推荐理由;不要把完整目录贴给用户。
|
||||
3. 锁定模板后,复用 `<theme>`、配色、页面流、布局骨架;所有占位文案都必须改写为用户真实内容。
|
||||
4. `layout_lint.py` 有 error 时先修 XML,不要提交创建;只有 warning 时,检查是否是可接受的装饰/背景误报。
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清用户需求:主题、受众、页数、风格偏好
|
||||
- 如果需求明显落在已有模板场景内,主动提示用户“可以直接基于现成模板生成”,并给出 2-3 个最匹配模板候选(模板名 + 适用场景 + 风格/色调 + 简短推荐理由)
|
||||
- 默认不要把完整模板目录直接贴给用户;除非用户明确要求看更多,否则只展示 2-3 个候选
|
||||
- 候选优先选场景强相关模板;只有没有明显场景模板时,才用 `light_general.xml` / `dark_general.xml` 这类通用模板兜底
|
||||
- 如果用户没有明确风格,根据主题推荐(见下方风格判断表)
|
||||
- 如果用户要求“模板/主题/风格参考”,或主题属于常见模板场景:
|
||||
· 优先运行 `python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3` 做低成本模板匹配
|
||||
· 需要人类可读说明时,再读 template-catalog.md 组织候选文案
|
||||
· 锁定模板后,优先运行 `template_tool.py summarize` 看 `<theme>` / 页型摘要;需要具体布局时,再用 `template_tool.py extract`
|
||||
· 复用模板的 theme、配色、页面流、布局骨架,不要照搬占位文案
|
||||
· `references/template-index.json` 只是脚本缓存/轻量路由索引,`assets/templates/*.xml` 是机器资源;除非用户明确要求审计原始模板,否则不要直接读取
|
||||
- 读取 XML Schema 参考:
|
||||
· xml-schema-quick-ref.md — 元素和属性速查
|
||||
· xml-format-guide.md — 详细结构与示例
|
||||
· slides_demo.xml — 真实 XML 示例
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 创建
|
||||
- 生成大纲前,先确认用户是否采用推荐模板;轻量任务且候选中有明显最佳匹配时,可在大纲里声明“默认基于 <template-id> 改写”并继续,但正式创建前必须给用户改选机会
|
||||
- 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认
|
||||
- 如果已选模板,大纲和页面布局要明确标注“基于哪个模板/哪些模板改写”
|
||||
- 如果用户明确不要模板,直接按自定义风格继续,不要重复推动模板选择
|
||||
- 先判断创建方式:
|
||||
· 简单 XML:可用 `slides +create --slides '[...]'` 一步创建
|
||||
· 复杂 XML:优先先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
· 超过 10 页:默认使用两步创建,避免单次输入过长
|
||||
- 含本地图片:
|
||||
· 新建带图 PPT —— 在 slide XML 里写 <img src="@./pic.png" .../>,
|
||||
+create 会自动上传并替换为 file_token(详见 lark-slides-create.md)
|
||||
· 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID`
|
||||
拿到 file_token,再用它写进 slide XML 调 xml_presentation.slide.create
|
||||
· 给已有页加图 —— 两步:① `slides +media-upload` 拿 file_token
|
||||
② `slides +replace-slide --parts '[{"action":"block_insert","insertion":"<img src=\"<file_token>\" .../>"}]'`
|
||||
不动其他元素,不要再整页重建(完整示例见 lark-slides-edit-workflows.md 的 block_insert 章节)
|
||||
· 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png);
|
||||
绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行
|
||||
- 每页 slide 需要完整的 XML:背景、文本、图形、配色
|
||||
- 复杂元素(table、chart)需参考 XSD 原文
|
||||
- 创建前必须做 XML 自检:
|
||||
· 检查特殊字符是否按 XML 规则转义:文本节点和属性值里的裸 `& -> &`;文本里的 `< -> <`、`> -> >`。例如 `Q&A -> Q&A`,URL 属性 `a=1&b=2 -> a=1&b=2`
|
||||
· 属性值里的双引号必须转义或改为外层安全包装,避免 shell 和 JSON 双重截断
|
||||
· 确认所有标签闭合,且 `<slide>` 直接子元素只包含 `<style>`、`<data>`、`<note>`
|
||||
· 如果内容里同时出现中文、大段文本、复杂布局、较多特殊字符,默认不要走 `--slides '[...]'`,直接改用两步创建法
|
||||
· 如果 XML 已落到本地文件且可运行 Python,先执行 `layout_lint.py --input <file>`;它会先检查 XML well-formed 再检查布局风险,但不等价于完整 XSD schema 校验;有 error 先修复再创建
|
||||
- 如果使用模板生成页面,先复用模板骨架再填内容,不要直接复制模板中的长段占位文本
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML 做创建后验证,确认:
|
||||
· 页数是否正确?
|
||||
· 每页 `<data>` 是否包含预期的 `<shape>` / `<img>` / 其他元素?
|
||||
· 文本内容是否完整,是否有被截断、丢失、空白区域?
|
||||
· 关键布局坐标和尺寸是否合理,是否出现明显重叠?
|
||||
· 配色是否统一?字号层级是否合理?
|
||||
- 如果本地有 Python 3,运行
|
||||
`python3 skills/lark-slides/scripts/layout_lint.py --input presentation.xml`
|
||||
做重叠、越界、页脚碰撞、文本高度风险检查;有 error 先修复再交付
|
||||
- 如果创建过程中失败:
|
||||
· 先保留并记录 `xml_presentation_id`,不要假设失败代表什么都没创建
|
||||
· 先判断是否已有部分页面写入,再决定是否在现有 PPT 上修复后继续追加
|
||||
· 优先排查当前失败页:先看该页 XML,再检查是否存在未转义 `&`、错误引号、标签未闭合、shell 传参截断
|
||||
- 局部问题 → 用 `+replace-slide` 块级修正;整页结构要改 → `slide.delete` 旧页 + `slide.create` 新页
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
### 创建后验证
|
||||
|
||||
创建成功不等于内容正确。创建完 PPT 后,**必须**读取全文 XML 校验结果:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- [ ] 页数是否与预期一致
|
||||
- [ ] 每页 `<data>` 中是否包含所有预期元素
|
||||
- [ ] 文本内容是否完整,没有被 shell 截断或转义损坏
|
||||
- [ ] 白底内容区、卡片区、图文区等关键布局是否实际生成
|
||||
- [ ] 坐标、宽高是否合理,是否出现堆叠或越界
|
||||
|
||||
发现问题时:
|
||||
|
||||
1. 不要假设“创建成功就代表渲染正确”
|
||||
2. 先读取问题页的 XML,确认是生成问题还是传参损坏
|
||||
3. 删除问题页后重新添加;复杂页面优先改用两步创建法
|
||||
|
||||
### 最小验收清单
|
||||
|
||||
创建完成后,默认按下面顺序验收,不要省略:
|
||||
|
||||
1. 记录 `xml_presentation_id`
|
||||
2. 确认返回的 `slides_added` 或实际页数是否符合预期
|
||||
3. 立即执行 `xml_presentations get`
|
||||
4. 检查标题、关键页面、关键文本是否存在
|
||||
5. 检查是否有明显空白页、内容缺失、页序错误
|
||||
6. 再决定是否向用户交付 URL 和后续编辑建议
|
||||
|
||||
推荐最小闭环:
|
||||
|
||||
```bash
|
||||
# 创建
|
||||
lark-cli slides +create --as user --title "Demo" --slides '[...]'
|
||||
|
||||
# 立即回读
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
## XML 自检与排障
|
||||
|
||||
在真正创建前,至少做下面 4 项检查:
|
||||
|
||||
- [ ] 特殊字符已转义:正文和标题里的 `&`、`<`、`>` 不能裸写;属性值里的裸 `&` 也必须写成 `&`
|
||||
- [ ] 属性引号安全:XML 属性、shell 引号、JSON 字符串包装之间没有互相打断
|
||||
- [ ] 结构合法:`<slide>` 下只放 `<style>`、`<data>`、`<note>`,文本都在 `<content>` 内
|
||||
- [ ] 路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用
|
||||
|
||||
高频失败信号和处理顺序:
|
||||
|
||||
1. `invalid param` / 某一页创建失败
|
||||
2. 先检查失败页是否含未转义 `&` / `<` / `>`:`Q&A -> Q&A`,属性 URL `a=1&b=2 -> a=1&b=2`
|
||||
3. 再检查标签闭合、属性引号、`<content>` 结构
|
||||
4. 如果是 `--slides '[...]'`,怀疑 shell 截断时直接切两步创建法
|
||||
5. 创建后无论成功失败,都优先记录 `xml_presentation_id` 并回读确认是否已有部分页面写入
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
@@ -276,29 +196,7 @@ lark-cli slides xml_presentation.slide create \
|
||||
'{slide:{content:$content}, before_slide_id:$before}')"
|
||||
```
|
||||
|
||||
### 风格快速判断表
|
||||
|
||||
> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
|
||||
| 场景/主题 | 推荐风格 | 背景 | 主色 | 文字色 |
|
||||
|----------|---------|------|------|-------|
|
||||
| 科技/AI/产品 | 深色科技风 | 深蓝渐变 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)` | 蓝色系 `rgb(59,130,246)` | 白色 |
|
||||
| 商务汇报/季度总结 | 浅色商务风 | 浅灰 `rgb(248,250,252)` | 深蓝 `rgb(30,60,114)` | 深灰 `rgb(30,41,59)` |
|
||||
| 教育/培训 | 清新明亮风 | 白色 `rgb(255,255,255)` | 绿色系 `rgb(34,197,94)` | 深灰 `rgb(51,65,85)` |
|
||||
| 创意/设计 | 渐变活力风 | 紫粉渐变 `linear-gradient(135deg,rgba(88,28,135,1) 0%,rgba(190,24,93,1) 100%)` | 粉紫色系 | 白色 |
|
||||
| 周报/日常汇报 | 简约专业风 | 浅灰 `rgb(248,250,252)` + 顶部彩色渐变条 | 蓝色 `rgb(59,130,246)` | 深色 `rgb(15,23,42)` |
|
||||
| 用户未指定 | 默认简约专业风 | 同上 | 同上 | 同上 |
|
||||
|
||||
### 页面布局建议
|
||||
|
||||
| 页面类型 | 布局要点 |
|
||||
|---------|---------|
|
||||
| 封面页 | 居中大标题 + 副标题 + 底部信息,背景用渐变或深色 |
|
||||
| 数据概览页 | 指标卡片横排(rect 背景 + 大号数字 + 小号说明),下方列表或图表 |
|
||||
| 内容页 | 左侧竖线装饰 + 标题,下方分栏或列表 |
|
||||
| 对比/表格页 | table 元素或并列卡片,表头深色背景白字 |
|
||||
| 图表页 | chart 元素(column/line/pie),配合文字说明 |
|
||||
| 结尾页 | 居中感谢语 + 装饰线,风格与封面呼应 |
|
||||
> 渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
|
||||
### 大纲模板
|
||||
|
||||
@@ -319,12 +217,6 @@ N. 结尾页:[结尾文案]
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
|
||||
### 常用 Slide XML 模板
|
||||
|
||||
可直接复制使用的模板(封面页、内容页、数据卡片页、结尾页):[slide-templates.md](references/slide-templates.md)
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### URL 格式与 Token
|
||||
@@ -338,46 +230,13 @@ N. 结尾页:[结尾文案]
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、幻灯片等不同类型的文档。**不能直接假设 URL 中的 token 就是 `xml_presentation_id`**,必须先查询实际类型和真实 token。
|
||||
|
||||
#### 处理流程
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型,幻灯片对应 `slides`
|
||||
- `node.obj_token`:**真实的演示文稿 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
|
||||
3. **确认 `obj_type` 为 `slides` 后,使用 `obj_token` 作为 `xml_presentation_id`**
|
||||
|
||||
#### 查询示例
|
||||
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先查询 wiki 节点,确认 `node.obj_type == "slides"`,再用 `node.obj_token` 作为真实 presentation ID。
|
||||
|
||||
```bash
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wikcnxxxxxxxxx"}'
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "slides",
|
||||
"obj_token": "xxxxxxxxxxxx",
|
||||
"title": "2026 产品年度总结",
|
||||
"node_type": "origin",
|
||||
"space_id": "1234567890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 用 obj_token 读取幻灯片内容
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"xxxxxxxxxxxx"}'
|
||||
```
|
||||
Shortcut `+replace-slide` 和 `+media-upload` 会自动解析 `/wiki/` URL;手动调用 `xml_presentations.*` / `xml_presentation.slide.*` 时才需要自己做这一步。
|
||||
|
||||
### 资源关系
|
||||
|
||||
@@ -393,39 +252,26 @@ Slides (演示文稿)
|
||||
└── slide_id (页面唯一标识)
|
||||
```
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
## Shortcuts 与 API
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传),bot 模式自动授权 |
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### xml_presentations
|
||||
|
||||
- `get` — 读取演示文稿全文信息,XML 格式返回
|
||||
|
||||
### xml_presentation.slide
|
||||
|
||||
- `create` — 在指定 XML 演示文稿下创建页面
|
||||
- `delete` — 在指定 XML 演示文稿下删除页面
|
||||
- `get` — 获取指定 XML 演示文稿的单个页面 XML 内容
|
||||
- `replace` — 对指定 XML 演示文稿页面进行元素级别的局部替换
|
||||
原生 API 高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段。
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先定模板/风格并出大纲再动手**:如果需求可匹配模板,先给用户 2-3 个模板候选;模板或自定义风格确定后,再生成大纲交给用户确认,避免返工
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
@@ -434,7 +280,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 权限表
|
||||
## 权限速查
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
@@ -447,79 +293,4 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
| `xml_presentation.slide.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.replace` | `slides:presentation:update` |
|
||||
|
||||
## 常见错误速查
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 400 | XML 格式错误 | 检查 XML 语法,确保标签闭合 |
|
||||
| 400 | 请求包装错误 | 检查 `--data` 是否按 schema 传入 `xml_presentation.content` 或 `slide.content` |
|
||||
| 创建成功但页面空白/内容缺失/布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建:先 `slides +create`,再用 `jq -n` 包装 `xml_presentation.slide.create` 逐页添加,并在创建后立即读取 XML 验证 |
|
||||
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
|
||||
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 |
|
||||
| 403 | 权限不足 | 检查是否拥有对应的 scope |
|
||||
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
|
||||
| 1061002 | params error(媒体上传时) | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` |
|
||||
| 1061004 | forbidden:当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 |
|
||||
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 `xml_presentation.slide.replace` 失败(catch-all) | 优先检查未转义 `&` / `<` / `>`:`Q&A -> Q&A`,属性 URL `a=1&b=2 -> a=1&b=2`;运行 `layout_lint.py --input <file>` 定位行列和上下文;再检查 replace 场景的 `block_id` / `<content/>` / 坐标 |
|
||||
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
|
||||
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
|
||||
|
||||
## 创建前自查
|
||||
|
||||
逐页生成 XML 前,快速检查:
|
||||
|
||||
- [ ] 每页背景色/渐变是否设置?风格是否与整体一致?
|
||||
- [ ] 标题用大字号(28-48),正文用小字号(13-16),层级分明?
|
||||
- [ ] 同类元素配色一致?(如所有指标卡片同色系、所有正文同色)
|
||||
- [ ] 装饰元素(分割线、色块、竖线)颜色是否与主色协调?
|
||||
- [ ] 文本框尺寸是否足够容纳内容?(宽度 × 高度)
|
||||
- [ ] shape 的 `type` 是否正确?(文本框用 `text`,装饰用 `rect`)
|
||||
- [ ] XML 标签是否全部正确闭合?特殊字符(`&`、`<`、`>`)是否转义?
|
||||
|
||||
## 症状 → 修复表
|
||||
|
||||
| 看到的问题 | 改什么 |
|
||||
|-----------|--------|
|
||||
| 文字被截断/看不全 | 增大 shape 的 `width` 或 `height` |
|
||||
| 元素重叠 | 调整 `topLeftX`/`topLeftY`,拉开间距 |
|
||||
| 页面大面积空白 | 缩小元素间距,或增加内容填充 |
|
||||
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
|
||||
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
|
||||
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 |
|
||||
| 图片被裁掉一部分 | `<img>` 的 `width`/`height` 是裁剪后尺寸,比例和原图不一致时会自动裁剪;要整图显示就让 `width:height` 对齐原图比例 |
|
||||
| 只想改某页的单个元素(文字/图片/形状) | 用 `+replace-slide` 块级替换,不要整页重建 |
|
||||
| 想给已有页加一张图(不动原有元素) | ① `+media-upload` 拿 `file_token` ② `+replace-slide` 用 `block_insert` 插入 `<img src="<file_token>" .../>`;不要再用 "整页 create + delete" 的老流程 |
|
||||
| 新插入的 `<img>` 挡住/重叠原有元素 | `slide.get` 读原页,对照已有块的 `topLeftX/Y/width/height` 挑空白位置;空间不够就在同一批 `--parts` 里先 `block_replace` 缩小/挪动现有块再 `block_insert` 图片 |
|
||||
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 |
|
||||
| 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) |
|
||||
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
|
||||
| API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 |
|
||||
| API 返回 3350001 | `block_replace` 根元素缺 `id=<block_id>` 或 `<shape>` 缺 `<content/>`,详见 replace-slide 文档 |
|
||||
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符**只在 `+create --slides` 中替换**;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` 写进 src |
|
||||
| 上传图片报 1061002 params error | `parent_type` 必须是 `slide_file`(slides 唯一接受值);不要手拼,用 `slides +media-upload` |
|
||||
|
||||
## 参考文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut:创建 PPT(支持 `--slides` 一步添加页面,含 `@` 占位符自动上传图片)** |
|
||||
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut:上传本地图片,返回 `file_token`** |
|
||||
| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | **+replace-slide Shortcut:块级替换/插入,含合法根元素速查与 3350001 排错** |
|
||||
| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | 编辑已有页面的读-改-写流程与 action 决策树 |
|
||||
| [template-index.json](references/template-index.json) | **脚本缓存/轻量路由索引:由 `template_tool.py search` 使用,不是默认阅读入口** |
|
||||
| [template-catalog.md](references/template-catalog.md) | **按场景/色调匹配现成 PPT 模板,并定位到页型范围** |
|
||||
| [`scripts/template_tool.py`](scripts/template_tool.py) | **可选 Python 辅助脚本:`search` / `summarize` / `extract`,支持 `--layout-tag` 与 `extract --with-summary`** |
|
||||
| [`scripts/layout_lint.py`](scripts/layout_lint.py) | **本地预检脚本:先检查 XML well-formed,再检测重叠、越界、页脚碰撞、文本高度风险;不是完整 XSD schema 校验** |
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** |
|
||||
| [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 |
|
||||
| [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 |
|
||||
| [examples.md](references/examples.md) | CLI 调用示例 |
|
||||
| [slides_demo.xml](references/slides_demo.xml) | 真实 PPT 的完整 XML |
|
||||
| [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | **完整 Schema 定义**(唯一协议依据) |
|
||||
| [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | 读取 PPT 命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | 添加幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | 删除幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-get.md](references/lark-slides-xml-presentation-slide-get.md) | 读取单个幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-replace.md](references/lark-slides-xml-presentation-slide-replace.md) | 原生 slide.replace API 命令详情 |
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
124
skills/lark-slides/references/asset-planning.md
Normal file
124
skills/lark-slides/references/asset-planning.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Asset Planning
|
||||
|
||||
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
|
||||
|
||||
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, or placeholder regions.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
|
||||
|
||||
## JSON Shape
|
||||
|
||||
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show how API gateway, planner, XML generator, and Slides API interact.",
|
||||
"suggested_query": "agent native slides runtime architecture diagram",
|
||||
"fallback_if_missing": "Draw grouped boxes and arrows with XML shapes; use labels instead of an image."
|
||||
}
|
||||
```
|
||||
|
||||
For a page without a meaningful asset need, use:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "none",
|
||||
"purpose": "No external or simulated asset needed; the page is text-led.",
|
||||
"suggested_query": "",
|
||||
"fallback_if_missing": "Use typography, spacing, and simple accent shapes only."
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Asset Types
|
||||
|
||||
- `paper_figure`: figure from a paper or technical article.
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: line, bar, pie, funnel, scatter, or chart-like data visual.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
- `none`: explicitly no asset needed.
|
||||
|
||||
Do not invent new asset types unless the user asks for a special visual format. If a need is close to these types, choose the closest one and explain the detail in `purpose`.
|
||||
|
||||
## Planning Guidance
|
||||
|
||||
Match asset type to slide role:
|
||||
|
||||
- `architecture-diagram` layout usually pairs with `architecture_diagram` or `flow_diagram`.
|
||||
- `process-flow` layout usually pairs with `flow_diagram`, `icon`, or `infographic`.
|
||||
- `comparison` layout often works with `icon`, `chart`, or `infographic`.
|
||||
- `timeline` layout often works with `icon`, `chart`, or shape-based milestone markers.
|
||||
- `big-number` layout often works with `chart` or `infographic`, but only if it supports the metric.
|
||||
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, or `infographic`; if missing, use a large placeholder diagram or stylized panel.
|
||||
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
|
||||
- "Use a placeholder."
|
||||
- "Find another image."
|
||||
- "Leave blank if unavailable."
|
||||
- "Use generic decoration."
|
||||
|
||||
## Examples
|
||||
|
||||
Transformer Self-Attention page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "paper_figure",
|
||||
"purpose": "Explain token-to-token attention and why each output token mixes context.",
|
||||
"suggested_query": "Transformer self attention attention matrix diagram",
|
||||
"fallback_if_missing": "Draw a simplified attention matrix with token labels, colored weights, and arrows from input tokens to one highlighted output token."
|
||||
}
|
||||
```
|
||||
|
||||
System architecture page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show the runtime path from user prompt to plan, XML generation, Slides API creation, and fetch verification.",
|
||||
"suggested_query": "slides generation runtime architecture planner XML API verification",
|
||||
"fallback_if_missing": "Draw four grouped boxes connected left-to-right with arrows; put verification as a return arrow from Slides API to agent."
|
||||
}
|
||||
```
|
||||
|
||||
Business comparison page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "infographic",
|
||||
"purpose": "Make before/after differences scannable without dense bullet lists.",
|
||||
"suggested_query": "before after product workflow comparison infographic",
|
||||
"fallback_if_missing": "Use two side-by-side panels with matching icon circles and three parallel rows of concise labels."
|
||||
}
|
||||
```
|
||||
|
||||
## Plan To XML Contract
|
||||
|
||||
When generating XML:
|
||||
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, or chart-like elements.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
@@ -178,7 +178,7 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
| 400 | XML 格式错误 | 检查 `slide.content` 是否是完整 `<slide>` 元素 |
|
||||
| 400 | 请求体结构错误 | 检查是否按 `slide.content` 和 `before_slide_id` 包装 |
|
||||
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:update` 或 `slides:presentation:write_only` scope |
|
||||
| 3350001 | XML 非 well-formed 或服务端参数校验失败 | 优先检查未转义字符:文本 `Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`;创建前运行 `python3 skills/lark-slides/scripts/layout_lint.py --input <file>` 获取行列和上下文 |
|
||||
| 3350001 | XML 非 well-formed 或服务端参数校验失败 | 优先检查未转义字符:文本 `Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2` |
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -188,8 +188,7 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
4. **fill / border 写法**: 颜色填充使用 `<fill><fillColor color="..."/></fill>`,边框常用 `<border color="..." width="2"/>`
|
||||
5. **插入位置**: 通过 `before_slide_id` 指定插入目标,而不是用 `position`
|
||||
6. **JSON 转义**: 如果直接内联 XML,需要正确转义双引号
|
||||
7. **本地预检**: 创建前运行 `layout_lint.py --input <file>`;它检查 XML well-formed 和布局风险,不等价于完整 XSD schema 校验
|
||||
8. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
|
||||
7. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
|
||||
|
||||
## 批量添加建议
|
||||
|
||||
|
||||
219
skills/lark-slides/references/planning-layer.md
Normal file
219
skills/lark-slides/references/planning-layer.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Planning Layer
|
||||
|
||||
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构,仍然需要规划层。
|
||||
|
||||
## Required Flow
|
||||
|
||||
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
|
||||
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
|
||||
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`。
|
||||
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`。
|
||||
5. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
6. 读取 `xml-schema-quick-ref.md`、`visual-planning.md` 和 `asset-planning.md`。
|
||||
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
|
||||
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
|
||||
## Plan Path
|
||||
|
||||
Use a separate plan directory per deck or task so multiple presentations in the same workspace cannot overwrite each other.
|
||||
|
||||
Recommended IDs:
|
||||
|
||||
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
|
||||
- Existing PPT rewrite: the `xml_presentation_id`.
|
||||
- Ambiguous or untitled task: short task slug plus date/time.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not reuse `.lark-slides/plan/slide_plan.json` as a shared path.
|
||||
- Create the directory before writing the file.
|
||||
- Reuse the same plan path for XML generation and post-create verification for that deck.
|
||||
|
||||
## Artifact Lifecycle
|
||||
|
||||
`.lark-slides/` is local agent state. It supports recovery, iteration, and later edits, but it should not be treated as source code or committed by default.
|
||||
|
||||
Keep:
|
||||
|
||||
- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
|
||||
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.
|
||||
|
||||
Clean or avoid keeping:
|
||||
|
||||
- Transient XML payloads after successful creation and verification. Prefer `/tmp` for throwaway XML, or delete generated XML files after success.
|
||||
- Stale XML drafts that no longer match the current presentation state.
|
||||
|
||||
Exception:
|
||||
|
||||
- If creation fails or partially succeeds, keep the relevant XML/debug payloads until recovery is complete. Record `xml_presentation_id` first, then fetch current state before retrying.
|
||||
|
||||
## JSON Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
|
||||
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
|
||||
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
|
||||
"visual_system": {
|
||||
"background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
|
||||
"motif": "A reusable left accent bar and consistent card/header treatments.",
|
||||
"color_roles": {
|
||||
"primary": "Used for the dominant structural motif and about 60-70% of visual weight.",
|
||||
"secondary": "Used for grouped regions, comparison panels, or supporting categories.",
|
||||
"accent": "Used only for key numbers, conclusions, or focus markers."
|
||||
}
|
||||
},
|
||||
"typography_constraints": {
|
||||
"title_max_lines": 2,
|
||||
"body_max_lines_per_box": 2,
|
||||
"footer_max_lines": 1,
|
||||
"long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
|
||||
},
|
||||
"verification_plan": {
|
||||
"check_background_consistency": true,
|
||||
"check_text_fit": true,
|
||||
"check_visual_focus": true,
|
||||
"check_asset_rendering": true
|
||||
},
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"title": "Proposal Title",
|
||||
"key_message": "The initiative is ready for a focused pilot.",
|
||||
"layout_type": "title-cover",
|
||||
"visual_focus": "Large title area with one concise supporting statement.",
|
||||
"asset_need": {
|
||||
"asset_type": "logo",
|
||||
"purpose": "Signal product or team identity on the opening page.",
|
||||
"suggested_query": "product logo",
|
||||
"fallback_if_missing": "Use a small text badge and abstract shape motif instead of a real logo."
|
||||
},
|
||||
"text_density": "low",
|
||||
"speaker_intent": "Frame the decision and establish the deck's point of view."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Required Fields
|
||||
|
||||
Top-level fields:
|
||||
|
||||
- `presentation_goal`: what the whole deck is trying to achieve.
|
||||
- `audience`: target readers or listeners and their assumed background.
|
||||
- `theme_style`: visual tone, palette direction, and professional style.
|
||||
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
|
||||
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
|
||||
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
|
||||
- `slides`: ordered page plans.
|
||||
|
||||
Each slide must include:
|
||||
|
||||
- `page`: 1-based page number.
|
||||
- `title`: slide title.
|
||||
- `key_message`: the one idea this page must land.
|
||||
- `layout_type`: planned page structure.
|
||||
- `visual_focus`: dominant visual object or region.
|
||||
- `asset_need`: planning-only structured asset metadata; no search, download, or upload required. Follow `asset-planning.md`.
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
|
||||
- `title-cover`
|
||||
- `section-divider`
|
||||
- `two-column`
|
||||
- `image-left-text-right`
|
||||
- `image-right-text-left`
|
||||
- `big-number`
|
||||
- `timeline`
|
||||
- `comparison`
|
||||
- `architecture-diagram`
|
||||
- `process-flow`
|
||||
- `quote-highlight`
|
||||
- `conclusion`
|
||||
|
||||
The value must affect XML geometry, not just appear as a label. For example, `timeline` should create a horizontal or vertical sequence, `comparison` should create distinct side-by-side regions, and `big-number` should reserve dominant space for a large metric.
|
||||
|
||||
## Text Density Rules
|
||||
|
||||
- `low`: title plus 1 short statement, or 1-3 very short labels.
|
||||
- `medium`: title plus 2-4 concise bullets or labeled regions.
|
||||
- `high`: allowed only when the user needs detail; use tables, columns, or grouped regions instead of a long bullet list.
|
||||
|
||||
Do not let all pages become title + bullet slides. For decks of 4 or more pages, aim for at least 4 different `layout_type` values when the content allows it.
|
||||
|
||||
Text density must be realistic for the planned geometry. If a page needs long titles, bilingual labels, paper figure captions, legal disclaimers, or dense technical wording, record how the text will be shortened, split, or moved to speaker notes. Do not rely on small font sizes or tight boxes to make text fit.
|
||||
|
||||
## Visual System Planning
|
||||
|
||||
Before generating XML, define a visual system that can survive the whole deck:
|
||||
|
||||
- `background_strategy`: specify the default background for normal content pages, and which page roles may intentionally differ. Do not let pages drift through near-identical but inconsistent background colors.
|
||||
- `motif`: choose one or two reusable structural devices, such as a side bar, header rail, numbered node, card treatment, diagram lane, or section band. The motif should appear consistently enough that pages feel related.
|
||||
- `color_roles`: assign primary, secondary, and accent roles. The same color must not mean unrelated things across pages.
|
||||
- `cover_content_relationship`: if the cover uses a different dark or image-led treatment, state how it connects to content pages through shared colors, motifs, or geometry.
|
||||
- `closing_relationship`: if the closing page mirrors the cover, state that explicitly so it looks intentional rather than like a new theme.
|
||||
|
||||
These are planning constraints, not decoration notes. They must affect coordinates, background fills, shape styles, and text placement in generated XML.
|
||||
|
||||
## Iterative Deck State
|
||||
|
||||
When continuing an existing deck, update the same plan path rather than creating a new disconnected plan. Keep the plan aligned with what has actually been created.
|
||||
|
||||
Recommended optional fields for long-running work:
|
||||
|
||||
- `deck_status`: current slide count, target slide count if known, and last verified revision or timestamp.
|
||||
- `created_slides`: page number, slide id when known, and the page role.
|
||||
- `assets_used`: source, local path when applicable, uploaded token when known, and which page uses it.
|
||||
- `open_issues`: known layout, text fit, asset, or consistency risks that still need correction.
|
||||
|
||||
Do not hard-code a page number just because a previous deck used that pattern. Plan by page role and evidence need, such as "method overview pages should use a figure when the source has a readable figure" instead of binding screenshots, charts, or diagrams to a fixed page index. The plan should describe decision rules, not a rigid template sequence.
|
||||
|
||||
## Asset Planning
|
||||
|
||||
`asset_need` is metadata. It can describe a desired figure, diagram, chart, icon, logo, screenshot, or fallback shape-based visual, but it must not require web search, local download, or media upload.
|
||||
|
||||
Use an object for one planned asset, an array for multiple real needs, or `asset_type: "none"` when no asset is useful. Each planned asset must include:
|
||||
|
||||
- `asset_type`: one of `paper_figure`, `architecture_diagram`, `icon`, `logo`, `chart`, `infographic`, `screenshot`, `flow_diagram`, or `none`.
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, arrows, labels, tables, simple charts, or placeholder panels.
|
||||
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
|
||||
Good examples:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw grouped boxes and arrows with short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple line chart with shapes and value labels."}`
|
||||
|
||||
## XML Generation Contract
|
||||
|
||||
Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
|
||||
- `key_message` determines the headline, dominant claim, or main takeaway.
|
||||
- `layout_type` determines the coordinate structure and element types. Use `visual-planning.md` for concrete layout rules.
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
|
||||
- Page count matches the plan.
|
||||
- Every page has the planned title and key message represented.
|
||||
- At least several pages have visibly different XML layout structures.
|
||||
- Planned `visual_focus` appears as a dominant visual region or object.
|
||||
- Asset planning is proportional to the deck topic and length: technical, research, product, and analytical decks should include meaningful planned visuals where they clarify the story, and each planned asset has a visible fallback if no real asset was used.
|
||||
- `text_density` is reflected in the amount of visible text.
|
||||
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
|
||||
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
|
||||
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
|
||||
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.
|
||||
@@ -17,7 +17,6 @@
|
||||
5. 优先运行 `template_tool.py summarize` 查看 `<theme>` / 页型摘要;只有需要具体布局骨架时,再运行 `template_tool.py extract`
|
||||
6. 从模板中提取并复用:`<theme>` 配色、页面流、shape 排列布局、装饰元素风格
|
||||
7. 将用户的实际内容填充到模板的结构框架中,**不要照搬模板的占位文字**
|
||||
8. 创建前运行 `layout_lint.py --input <file>`;它检查 XML well-formed 和布局风险,不等价于完整 XSD schema 校验
|
||||
|
||||
### 脚本快捷命令
|
||||
|
||||
|
||||
63
skills/lark-slides/references/troubleshooting.md
Normal file
63
skills/lark-slides/references/troubleshooting.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Troubleshooting
|
||||
|
||||
本文件覆盖 lark-slides 的通用创建前自检、XML 排障和常见失败处理。命令专属问题优先看对应 reference,例如 `+replace-slide`、`+media-upload`、`xml_presentation.slide.create`。
|
||||
|
||||
## XML Preflight
|
||||
|
||||
在真正创建或替换前,至少检查:
|
||||
|
||||
- 特殊字符已转义:正文和标题里的 `&`、`<`、`>` 不能裸写;属性值里的裸 `&` 也必须写成 `&`。
|
||||
- 属性引号安全:XML 属性、shell 引号、JSON 字符串包装之间没有互相打断。
|
||||
- 结构合法:`<slide>` 下只放 `<style>`、`<data>`、`<note>`,文本都在 `<content>` 内。
|
||||
- 图片路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用;直接调用 `xml_presentation.slide.create` 必须先拿到 `file_token`。
|
||||
|
||||
## Failure Order
|
||||
|
||||
遇到 `invalid param`、某一页创建失败、页面空白或布局错乱时,按顺序处理:
|
||||
|
||||
1. 记录 `xml_presentation_id`,不要假设失败代表什么都没创建。
|
||||
2. 用 `xml_presentations.get` 回读,确认是否已有部分页面写入。
|
||||
3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`。
|
||||
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`。
|
||||
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
|
||||
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
|
||||
|
||||
## Symptom Fixes
|
||||
|
||||
| 看到的问题 | 处理方式 |
|
||||
|-----------|----------|
|
||||
| 文字被截断 / 看不全 | 增大 shape 的 `width` 或 `height`,或减少文本量 |
|
||||
| 元素重叠 | 调整 `topLeftX` / `topLeftY`,拉开间距 |
|
||||
| 页面大面积空白 | 回读确认内容是否写入;若内容存在,再缩小间距或增加主体元素 |
|
||||
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
|
||||
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
|
||||
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1` / `dim2` 数据数量是否匹配 |
|
||||
| 图片被裁掉一部分 | `<img>` 的 `width` / `height` 是裁剪后尺寸;要整图显示就让 `width:height` 对齐原图比例 |
|
||||
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符只在 `+create --slides` 中替换;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` |
|
||||
| 新插入的 `<img>` 挡住原有元素 | `slide.get` 读原页,对照已有块坐标挑空白位置;空间不够就在同一批 `--parts` 里先移动/缩小现有块再插图 |
|
||||
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)` |
|
||||
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
|
||||
|
||||
## Common Errors
|
||||
|
||||
| 错误码 / 信号 | 含义 | 解决方案 |
|
||||
|--------------|------|----------|
|
||||
| 400 XML 格式错误 | XML 语法错误 | 检查标签闭合、属性引号、特殊字符转义 |
|
||||
| 400 请求包装错误 | `--data` 未按 schema 包装 | 检查是否传入 `xml_presentation.content` 或 `slide.content` |
|
||||
| 创建成功但页面空白 / 内容缺失 / 布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建,并在创建后立即读取 XML 验证 |
|
||||
| 403 权限不足 | 身份或 scope 不匹配 | 先检查是否误用了 bot 身份,再确认 scope 和文档权限 |
|
||||
| 404 演示文稿不存在 | `xml_presentation_id` 不正确或无权限 | 检查 token;wiki URL 需先解析真实 `obj_token` |
|
||||
| 404 幻灯片不存在 | `slide_id` 不正确 | 重新读取 presentation 或 slide,确认最新 ID |
|
||||
| 400 无法删除唯一幻灯片 | 演示文稿至少保留一页 | 先创建新页,再删除旧页 |
|
||||
| 1061002 媒体上传 params error | slides 媒体上传参数不符合约定 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` |
|
||||
| 1061004 forbidden | 当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建 |
|
||||
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符;replace 场景再看 `block_id` 和 `<content/>` |
|
||||
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
|
||||
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
|
||||
|
||||
## Command-Specific References
|
||||
|
||||
- 图片上传、`@path` 占位符、`file_token`:见 [lark-slides-media-upload.md](lark-slides-media-upload.md) 和 [lark-slides-create.md](lark-slides-create.md)。
|
||||
- 块级替换、`block_id`、3350001 replace 细节:见 [lark-slides-replace-slide.md](lark-slides-replace-slide.md)。
|
||||
- 原生 `slide.create` 包装、`before_slide_id` 和 jq 模板:见 [lark-slides-xml-presentation-slide-create.md](lark-slides-xml-presentation-slide-create.md)。
|
||||
102
skills/lark-slides/references/validation-checklist.md
Normal file
102
skills/lark-slides/references/validation-checklist.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Validation Checklist
|
||||
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。
|
||||
|
||||
小型已有页编辑也要做对应范围的验证:至少读取被改页面或全文 XML,确认目标元素已更新且未破坏周边结构。
|
||||
|
||||
## Required Flow
|
||||
|
||||
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`。
|
||||
2. 用 `xml_presentations.get` 回读全文 XML。
|
||||
3. 检查实际页数是否符合计划或用户要求。
|
||||
4. 检查每页 `<data>` 内是否有预期主要元素。
|
||||
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
6. 检查页面不是全部退化为标题加 bullet list。
|
||||
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
|
||||
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
|
||||
9. 在最终回复中给出简短验证记录。
|
||||
|
||||
回读命令:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
## Automated XML Text Overlap Lint
|
||||
|
||||
回读 XML 保存到本地文件后,优先运行 XML 语法和文本重叠静态检查:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
|
||||
```
|
||||
|
||||
通过标准:
|
||||
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
|
||||
常见 code 的处理方向:
|
||||
|
||||
| code | 含义 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。
|
||||
- 如果创建过程部分失败,先记录已创建的 `xml_presentation_id`,再回读确认哪些页已写入。
|
||||
- 每页都应包含 `<data>`,且 `<data>` 内至少有一个非背景主体元素。
|
||||
- 封面、章节页、总结页可以文字较少,但不能只有空背景。
|
||||
- 技术解释页、对比页、流程页、架构页必须有匹配的结构元素,例如分组框、连线、时间轴、表格或图形化区域。
|
||||
|
||||
## Expected Elements
|
||||
|
||||
按 `slide_plan.json` 和用户要求逐页核对:
|
||||
|
||||
- 标题或主结论存在,并能对应 `key_message`。
|
||||
- `layout_type` 对应的主要结构已生成。
|
||||
- `visual_focus` 是页面中最醒目或最大的信息区域之一。
|
||||
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
|
||||
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
|
||||
|
||||
如果用户指定了关键页,例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
|
||||
|
||||
## Blank Or Broken Page Signals
|
||||
|
||||
把下面情况视为需要修复后再交付:
|
||||
|
||||
- `<data/>` 为空,或只有背景、装饰线、空 `<content/>`。
|
||||
- 关键文本没有出现在回读 XML 中。
|
||||
- 图片仍是 `@./path`,或 `<img src>` 是 http(s) 外链。
|
||||
- 页面依赖的图片区域为空,且没有 fallback visual。
|
||||
- 返回 XML 缺页、页序明显错误,或某页内容被 shell 截断。
|
||||
- 大量形状坐标完全相同,导致主体内容重叠。
|
||||
- 渐变背景回退成空白或白底,导致文字不可读。
|
||||
|
||||
## Layout And Overflow Risk
|
||||
|
||||
优先修复这些明显风险:
|
||||
|
||||
- 正文或标签框高度不足,文本很可能被截断。
|
||||
- 多个主体元素在同一区域重叠,而不是有意叠加背景。
|
||||
- 重要内容越过画布边界,或贴近底部超过 `y=500`。
|
||||
- 高密度页使用单个长 bullet list,没有分栏、表格或分组。
|
||||
- 标题、主视觉、正文的字号和颜色差异太弱,视觉层级不清。
|
||||
- 所有内容页都是同一套标题加 bullets 坐标。
|
||||
|
||||
## Verification Record
|
||||
|
||||
最终回复必须包含简短验证记录,建议格式:
|
||||
|
||||
```text
|
||||
验证记录:
|
||||
- 回读:已执行 xml_presentations.get,实际页数 N / 预期 N。
|
||||
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
|
||||
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
|
||||
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
|
||||
```
|
||||
|
||||
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。
|
||||
250
skills/lark-slides/references/visual-planning.md
Normal file
250
skills/lark-slides/references/visual-planning.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Visual Planning
|
||||
|
||||
新建演示文稿或大幅改写页面时,在 `slide_plan.json` 完成后、生成 XML 前读取本文件。目标是让 `layout_type`、`visual_focus`、`text_density` 变成实际页面几何,而不是只写在 plan 里。
|
||||
|
||||
默认画布按 `960 x 540` 规划。模板 XML 可以覆盖具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `layout_type` must change geometry: element positions, region sizes, alignment, and visual rhythm must differ across page types.
|
||||
- `visual_focus` determines the largest or highest-contrast region. It can be an image, diagram, metric, quote, table, or shape-based placeholder.
|
||||
- `text_density` caps visible text:
|
||||
- `low`: title plus one short statement, or 1-3 labels.
|
||||
- `medium`: title plus 2-4 concise bullets or labeled regions.
|
||||
- `high`: use a table, columns, grouped labels, or annotations. Do not use one long bullet box.
|
||||
- Do not create a deck where every content page is title plus bullets. For 4 or more pages, use at least 4 different layout structures when the content allows.
|
||||
- Keep generous margins. Use `60-80` px outer margins on standard content pages unless a full-bleed image or cover treatment is intentional.
|
||||
- Reserve vertical space for titles. A typical content title area is `y=36..90`; main content should usually start at `y>=110`.
|
||||
- Avoid crowding the bottom edge. Keep non-background content above `y=500` unless it is a footer.
|
||||
- Prefer fewer, larger objects over many small text boxes.
|
||||
- Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change.
|
||||
- Treat text fit as a layout constraint, not a cleanup step. If a text box is too small for the intended line count, shorten the text, split it, or allocate more space before creating XML.
|
||||
|
||||
## Background And Motif Consistency
|
||||
|
||||
Decks can vary page backgrounds, but variation must be intentional and legible:
|
||||
|
||||
- Pick one default background for ordinary content pages and reuse it exactly. Avoid near-identical drift such as several slightly different off-white values unless it encodes a clear section change.
|
||||
- Cover, section divider, emphasis, and conclusion pages may use a dark, image-led, or high-contrast background. They must still share the deck's primary color, motif, edge treatment, typography, or geometry.
|
||||
- If a cover uses a split composition, make the split visible in the background or layout. For example, reserve a darker text region and a related but distinct visual region instead of placing all elements on one flat field.
|
||||
- Reuse a small number of visual devices: side bar, card radius, node style, line weight, icon container, or footer treatment. Do not introduce a new decorative language on each page.
|
||||
- Insert background and motif shapes before content elements so they do not cover text, images, or diagrams.
|
||||
|
||||
## Text Fit Guardrails
|
||||
|
||||
Use these as conservative minimums on a 960 x 540 canvas. Increase height when using bold text, Chinese text, mixed Chinese/English, or line spacing above default.
|
||||
|
||||
| Text use | Typical font size | Minimum height |
|
||||
|----------|-------------------|----------------|
|
||||
| Caption, 1 line | 10-12 | 18 |
|
||||
| Caption, 2 lines | 10-12 | 30 |
|
||||
| Body, 1 line | 13-16 | 24 |
|
||||
| Body, 2 lines | 13-16 | 40 |
|
||||
| Body, 2 lines, bold | 15-18 | 48 |
|
||||
| Headline, 1 line | 24-32 | 42 |
|
||||
| Title, 2 lines | 34-44 | 110 |
|
||||
|
||||
Additional rules:
|
||||
|
||||
- Do not put long Chinese sentences or long English phrases into `height=18` or `height=22` boxes. Those heights are for short labels only.
|
||||
- Footer/source text should usually be one short line. If it needs more, make it a real caption block above the footer area.
|
||||
- Bottom conclusion bars should be at least `40` px tall for one emphasized line and at least `54` px tall for two lines.
|
||||
- Diagram labels should be short enough to fit the shape. Prefer two short lines over one cramped long line.
|
||||
- When a text block has more than one `<p>`, size the box for multiple lines explicitly. Do not assume the renderer will auto-expand.
|
||||
- If a line contains mixed Chinese and English, budget more width than either language alone; mixed text wraps less predictably.
|
||||
|
||||
## Layout Types
|
||||
|
||||
### `title-cover`
|
||||
|
||||
Purpose: introduce the deck's point of view.
|
||||
|
||||
Geometry:
|
||||
- Use one dominant title block, usually `x=70..120`, `y=150..250`, `width=700..820`.
|
||||
- Add one subtitle or context line, not a bullet list.
|
||||
- Optional visual focus can be a full-bleed background, large side image, accent band, or abstract shape motif.
|
||||
- If the cover has a right-side diagram, screenshot, or motif cluster, use a split layout: keep the title/subtitle region within the left or central text region, and reserve a separate visual region so labels and connectors do not cross the title.
|
||||
- For split covers, make the background reinforce the composition, such as a darker text side and a related visual panel. Avoid one flat field where title and diagram compete for attention.
|
||||
- Keep source metadata to one short line where possible. If it wraps, shorten author lists or move details to notes.
|
||||
- The main title should be controlled, normally one or two lines. Do not let it occupy both the text region and the visual region.
|
||||
|
||||
Text:
|
||||
- `low` only unless the user explicitly asks for detail.
|
||||
|
||||
### `section-divider`
|
||||
|
||||
Purpose: reset rhythm and mark a new chapter.
|
||||
|
||||
Geometry:
|
||||
- Use a large section number, chapter label, or single centered claim.
|
||||
- Keep the page sparse. A divider is not a content page.
|
||||
- Visual focus can be one oversized number, a vertical accent bar, or a full-width band.
|
||||
|
||||
Text:
|
||||
- Title plus one phrase. No bullets.
|
||||
|
||||
### `two-column`
|
||||
|
||||
Purpose: compare two related ideas or pair explanation with evidence.
|
||||
|
||||
Geometry:
|
||||
- Split main region into two balanced columns, for example left `x=60,width=400`, right `x=500,width=400`.
|
||||
- Each column needs its own heading or visual anchor.
|
||||
- Do not place one full-width bullet box under a normal title; that is not a two-column layout.
|
||||
|
||||
Text:
|
||||
- `medium`: 2-3 short items per column.
|
||||
- `high`: use grouped rows or mini table structure inside columns.
|
||||
|
||||
### `image-left-text-right`
|
||||
|
||||
Purpose: let a visual establish context, with text explaining implication.
|
||||
|
||||
Geometry:
|
||||
- Left visual region should occupy roughly `35-45%` of slide width, often full height or tall crop.
|
||||
- Right text region starts around `x=420` and should have a strong headline plus short support.
|
||||
- If no real image is available, create a shape-based placeholder visual that matches `asset_need`.
|
||||
- For dense screenshots, paper figures, or product captures with small labels, allocate a larger visual region when possible: often `50-65%` of slide width or at least `320` px height.
|
||||
- Place screenshots in a deliberate frame or panel, and leave enough margin so axes, captions, and edge labels are not cropped by the slide boundary.
|
||||
|
||||
Text:
|
||||
- Keep right-side text short. Avoid more than 4 bullets.
|
||||
- For screenshot explanation pages, prefer 2-3 interpretation cards or callouts instead of a paragraph block.
|
||||
|
||||
### `image-right-text-left`
|
||||
|
||||
Purpose: lead with a message, then reinforce it with a visual.
|
||||
|
||||
Geometry:
|
||||
- Left text region starts around `x=60..90`, width `400..460`.
|
||||
- Right visual region occupies roughly `35-45%` of slide width.
|
||||
- Align the image or placeholder with the main text block, not only with the title.
|
||||
- For dense screenshots, paper figures, or product captures with small labels, increase the visual region and reduce text. A readable image is more valuable than a fully populated text column.
|
||||
|
||||
Text:
|
||||
- Use one main claim and 2-3 supporting points.
|
||||
- Keep callouts parallel and short. If a callout needs more than two lines, split it into a separate note or a new slide.
|
||||
|
||||
### `big-number`
|
||||
|
||||
Purpose: make one metric or fact memorable.
|
||||
|
||||
Geometry:
|
||||
- Reserve the largest object for the metric: font size often `64-110`, region at least `300 x 120`.
|
||||
- Pair the number with one explanation and optional 2-3 small supporting labels.
|
||||
- Do not bury the number in a bullet list or small card.
|
||||
|
||||
Text:
|
||||
- `low` or `medium`. If detail is needed, add small annotations around the metric.
|
||||
- Supporting labels must not compete with the number. Use compact labels, legends, or mini-cards rather than long explanatory bars.
|
||||
|
||||
### `timeline`
|
||||
|
||||
Purpose: show sequence, roadmap, history, or phases.
|
||||
|
||||
Geometry:
|
||||
- Create a horizontal or vertical spine with 3-6 milestones.
|
||||
- Each milestone should have a dot/card/date label connected by a line or arrow.
|
||||
- Title is separate from the sequence. The sequence is the visual focus.
|
||||
|
||||
Text:
|
||||
- Each milestone gets a short label and optional one-line explanation.
|
||||
- Do not use paragraph-length milestone descriptions.
|
||||
|
||||
### `comparison`
|
||||
|
||||
Purpose: make a choice, before/after, old/new, or option tradeoff clear.
|
||||
|
||||
Geometry:
|
||||
- Use two or three distinct panels, columns, or a table-like structure.
|
||||
- Headings must be visually aligned so differences are easy to scan.
|
||||
- Use color, border, icon, or label treatment to highlight the preferred option or key difference.
|
||||
|
||||
Text:
|
||||
- Use parallel wording across columns.
|
||||
- Avoid uneven long bullet lists that destroy comparability.
|
||||
|
||||
### `architecture-diagram`
|
||||
|
||||
Purpose: explain components, dependencies, or system flow.
|
||||
|
||||
Geometry:
|
||||
- Main visual area should be a diagram, not prose.
|
||||
- Use grouped boxes, lanes, arrows or lines, and short labels.
|
||||
- Keep diagram labels concise. Put explanation in notes or a small side caption if needed.
|
||||
|
||||
Text:
|
||||
- Prefer labels of 1-5 words.
|
||||
- Use no more than one short explanatory text block.
|
||||
- If a node label needs two lines, size the node and the text box for two lines. Do not let labels overlap connectors.
|
||||
|
||||
### `process-flow`
|
||||
|
||||
Purpose: show operational steps, workflow, or cause-effect path.
|
||||
|
||||
Geometry:
|
||||
- Use numbered steps connected by arrows or lines.
|
||||
- 3-5 steps is ideal for one slide. If there are more, group them into phases.
|
||||
- The flow direction must be visually obvious.
|
||||
|
||||
Text:
|
||||
- Each step gets a verb-led label and one short descriptor at most.
|
||||
- Step labels should be parallel in length and grammar. If one step needs a long explanation, move the explanation to a side note or speaker notes.
|
||||
|
||||
### `quote-highlight`
|
||||
|
||||
Purpose: emphasize a customer voice, principle, thesis, or decision statement.
|
||||
|
||||
Geometry:
|
||||
- Quote or claim is the dominant text object.
|
||||
- Use large type, generous whitespace, and optional attribution or context badge.
|
||||
- Do not combine a quote-highlight page with a normal bullet section.
|
||||
|
||||
Text:
|
||||
- One quote or statement, plus optional attribution. No bullets.
|
||||
|
||||
### `conclusion`
|
||||
|
||||
Purpose: close with decision, recommendation, or next action.
|
||||
|
||||
Geometry:
|
||||
- Use one dominant closing statement or call to action.
|
||||
- Add up to 3 next-step cards, checklist items, or owner/date labels.
|
||||
- Visual focus should be the recommendation or action, not decorative filler.
|
||||
|
||||
Text:
|
||||
- Keep the final page easy to remember. Avoid recap overload.
|
||||
- Conclusion pages may mirror the cover background, but must clearly reuse the deck's motif or color roles so the ending feels intentional.
|
||||
|
||||
## Screenshot And Paper Figure Pages
|
||||
|
||||
When a page uses a real screenshot, chart, paper figure, or product capture:
|
||||
|
||||
- Choose screenshot placement based on page role, not a fixed slide number. Method overview, evidence, comparison, and failure-analysis pages are common candidates; title, agenda, and conclusion pages usually are not.
|
||||
- Use the real asset only when it is readable at slide size. If the figure is too dense, crop to the relevant region, create a zoomed detail, or redraw the core message with native shapes.
|
||||
- A screenshot should normally be the visual focus. Do not shrink it into a decorative thumbnail while surrounding it with dense text.
|
||||
- Pair the image with a small number of interpretive annotations that tell the audience what to notice.
|
||||
- Always include a short source caption when using external or paper-derived visuals.
|
||||
- Verify the final XML contains a supported image token or creation-time local placeholder, not an unsupported external URL.
|
||||
|
||||
## Plan To XML Checklist
|
||||
|
||||
Before creating XML for each page, answer these checks:
|
||||
|
||||
1. Which region is the visual focus, and is it the largest or most prominent object?
|
||||
2. Does the XML geometry match the `layout_type` description above?
|
||||
3. Does `text_density` limit the number of paragraphs, bullets, labels, and text boxes?
|
||||
4. Would this page still be recognizable if the `layout_type` label were removed from the plan?
|
||||
5. Across the deck, do multiple pages use genuinely different structures?
|
||||
6. Does the background follow the planned deck strategy, and are any deviations intentional?
|
||||
7. Are all text boxes large enough for their intended font size and line count?
|
||||
8. If the page uses a screenshot or paper figure, is it large enough to read and accompanied by concise interpretation?
|
||||
|
||||
After fetching the created presentation, verify:
|
||||
|
||||
- Use `timeline`, `comparison`, and `architecture-diagram` only when the content calls for them; do not force irrelevant page types.
|
||||
- Any planned `timeline`, `comparison`, or `architecture-diagram` page uses the matching sequence, side-by-side comparison, or component-and-connection structure.
|
||||
- Pages are not crowded and do not rely on long bullet boxes.
|
||||
- Main claim, supporting detail, and visual focus have clear hierarchy.
|
||||
- Static XML inspection should include text-fit risk: very short text boxes containing long text, multi-paragraph boxes with insufficient height, footer text that may wrap, and labels placed directly over connectors.
|
||||
- Background and motif consistency should be checked across pages, not only within one slide.
|
||||
@@ -8,16 +8,17 @@ import json
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LayoutLintError(Exception):
|
||||
class XmlTextOverlapLintError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise LayoutLintError(message)
|
||||
raise XmlTextOverlapLintError(message)
|
||||
|
||||
|
||||
def read_file(file_path: str | Path) -> str:
|
||||
@@ -187,33 +188,30 @@ def is_text_element(element: dict[str, Any]) -> bool:
|
||||
return element["kind"] == "shape" and element["type"] == "text"
|
||||
|
||||
|
||||
def is_backgroundish(element: dict[str, Any], slide_area: int | float) -> bool:
|
||||
if slide_area <= 0:
|
||||
return False
|
||||
area = element["width"] * element["height"]
|
||||
if element["kind"] == "img":
|
||||
return area >= slide_area * 0.45
|
||||
if element["kind"] == "shape" and element["type"] != "text":
|
||||
return area >= slide_area * 0.35
|
||||
return False
|
||||
def has_text_content(element: dict[str, Any]) -> bool:
|
||||
return bool(element.get("text"))
|
||||
|
||||
|
||||
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any], slide_area: int | float) -> bool:
|
||||
if is_backgroundish(left, slide_area) or is_backgroundish(right, slide_area):
|
||||
def is_decorative_text(element: dict[str, Any]) -> bool:
|
||||
text = element.get("text") or ""
|
||||
return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
|
||||
|
||||
|
||||
def normalize_text_for_overlap(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
left_text = normalize_text_for_overlap(left.get("text") or "")
|
||||
right_text = normalize_text_for_overlap(right.get("text") or "")
|
||||
if not left_text or not right_text:
|
||||
return False
|
||||
if is_text_element(left) and is_text_element(right):
|
||||
if left_text == right_text or left_text in right_text or right_text in left_text:
|
||||
return True
|
||||
allowed_companions = {"img", "table", "chart"}
|
||||
return (
|
||||
is_text_element(left) and right["kind"] in allowed_companions
|
||||
) or (
|
||||
is_text_element(right) and left["kind"] in allowed_companions
|
||||
)
|
||||
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
|
||||
|
||||
|
||||
def estimate_text_height(element: dict[str, Any]) -> int | None:
|
||||
if element["kind"] != "shape" or element["type"] != "text" or not element.get("text"):
|
||||
return None
|
||||
def estimate_text_line_count(element: dict[str, Any]) -> int:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
chars_per_line = max(1, int(element["width"] // max(font_size * 0.55, 1)))
|
||||
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
|
||||
@@ -221,43 +219,88 @@ def estimate_text_height(element: dict[str, Any]) -> int | None:
|
||||
for paragraph in paragraphs:
|
||||
logical_length = max(len(paragraph), 1)
|
||||
line_count += max(1, -(-logical_length // chars_per_line))
|
||||
return int((line_count * font_size * 1.35) + 12 + 0.999999)
|
||||
return max(line_count, 1)
|
||||
|
||||
|
||||
def lint_slide(slide_xml: str, slide_number: int, width: int, height: int) -> dict[str, Any]:
|
||||
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
|
||||
return None
|
||||
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
char_width = max(font_size * 0.55, 1)
|
||||
line_count = estimate_text_line_count(element)
|
||||
visual_width = min(element["width"], max(1, len(element["text"]) * char_width))
|
||||
visual_height = min(element["height"], max(1, line_count * font_size * 1.2))
|
||||
return {
|
||||
"x": element["x"],
|
||||
"y": element["y"],
|
||||
"width": visual_width,
|
||||
"height": visual_height,
|
||||
}
|
||||
|
||||
|
||||
def intersection_area(left: dict[str, Any], right: dict[str, Any]) -> int | float:
|
||||
width = min(left["x"] + left["width"], right["x"] + right["width"]) - max(left["x"], right["x"])
|
||||
height = min(left["y"] + left["height"], right["y"] + right["height"]) - max(left["y"], right["y"])
|
||||
if width <= 0 or height <= 0:
|
||||
return 0
|
||||
return width * height
|
||||
|
||||
|
||||
def is_template_text_stack(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
if not (is_text_element(left) and is_text_element(right)):
|
||||
return False
|
||||
if not (has_text_content(left) and has_text_content(right)):
|
||||
return True
|
||||
top, bottom = sorted([left, right], key=lambda element: element["y"])
|
||||
top_type = top.get("textType")
|
||||
bottom_type = bottom.get("textType")
|
||||
allowed_pairs = {
|
||||
("title", "sub-headline"),
|
||||
("title", None),
|
||||
("headline", "headline"),
|
||||
("headline", None),
|
||||
}
|
||||
if (top_type, bottom_type) not in allowed_pairs:
|
||||
return False
|
||||
same_column = abs(top["x"] - bottom["x"]) <= 4
|
||||
vertical_offset = bottom["y"] - top["y"]
|
||||
top_font_size = float(top.get("fontSize", 16))
|
||||
return same_column and vertical_offset >= top_font_size * 0.75
|
||||
|
||||
|
||||
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
if is_text_element(left) and not has_text_content(left):
|
||||
return False
|
||||
if is_text_element(right) and not has_text_content(right):
|
||||
return False
|
||||
if is_template_text_stack(left, right):
|
||||
return False
|
||||
if is_text_element(left) and is_text_element(right):
|
||||
if is_similar_text_overlay(left, right):
|
||||
return False
|
||||
left_visual = estimate_text_visual_bbox(left)
|
||||
right_visual = estimate_text_visual_bbox(right)
|
||||
if left_visual is None or right_visual is None:
|
||||
return False
|
||||
overlap_area = intersection_area(left_visual, right_visual)
|
||||
if overlap_area <= 0:
|
||||
return False
|
||||
smaller_area = min(
|
||||
left_visual["width"] * left_visual["height"],
|
||||
right_visual["width"] * right_visual["height"],
|
||||
)
|
||||
return smaller_area > 0 and overlap_area / smaller_area >= 0.30
|
||||
return False
|
||||
|
||||
|
||||
def lint_slide(slide_xml: str, slide_number: int) -> dict[str, Any]:
|
||||
elements = extract_elements(slide_xml)
|
||||
issues: list[dict[str, Any]] = []
|
||||
slide_area = width * height
|
||||
|
||||
for element in elements:
|
||||
if (
|
||||
element["x"] < 0
|
||||
or element["y"] < 0
|
||||
or element["x"] + element["width"] > width
|
||||
or element["y"] + element["height"] > height
|
||||
):
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "out_of_bounds",
|
||||
"element": element["id"],
|
||||
"message": f'{element["id"]} exceeds slide bounds',
|
||||
}
|
||||
)
|
||||
estimated_height = estimate_text_height(element)
|
||||
if estimated_height is not None and estimated_height > element["height"]:
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "text_height_risk",
|
||||
"element": element["id"],
|
||||
"message": f'{element["id"]} may need {estimated_height}px height but only has {element["height"]}px',
|
||||
}
|
||||
)
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
for right in elements[index + 1 :]:
|
||||
if not intersects(left, right) or not should_flag_overlap(left, right, slide_area):
|
||||
if not intersects(left, right) or not should_flag_overlap(left, right):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
@@ -268,31 +311,6 @@ def lint_slide(slide_xml: str, slide_number: int, width: int, height: int) -> di
|
||||
}
|
||||
)
|
||||
|
||||
footer_candidates = [
|
||||
element
|
||||
for element in elements
|
||||
if element["kind"] == "shape"
|
||||
and element["type"] == "text"
|
||||
and element["y"] >= height - 80
|
||||
and element["height"] <= 60
|
||||
]
|
||||
for footer in footer_candidates:
|
||||
for element in elements:
|
||||
if (
|
||||
element["id"] == footer["id"]
|
||||
or not intersects(footer, element)
|
||||
or not should_flag_overlap(footer, element, slide_area)
|
||||
):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "footer_collision",
|
||||
"elements": [footer["id"], element["id"]],
|
||||
"message": f'{footer["id"]} is being crowded by {element["id"]}',
|
||||
}
|
||||
)
|
||||
|
||||
return {"slide_number": slide_number, "element_count": len(elements), "issues": issues}
|
||||
|
||||
|
||||
@@ -309,7 +327,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
|
||||
presentation = parse_presentation(xml)
|
||||
slides = [
|
||||
lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"])
|
||||
lint_slide(slide_xml, index + 1)
|
||||
for index, slide_xml in enumerate(presentation["slides"])
|
||||
]
|
||||
error_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
@@ -323,7 +341,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
print("Usage:\n python3 layout_lint.py --input <presentation.xml>", file=sys.stderr)
|
||||
print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
|
||||
|
||||
|
||||
def run_cli(argv: list[str] | None = None) -> None:
|
||||
@@ -344,6 +362,6 @@ def run_cli(argv: list[str] | None = None) -> None:
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
run_cli()
|
||||
except LayoutLintError as error:
|
||||
print(f"layout-lint error: {error}", file=sys.stderr)
|
||||
except XmlTextOverlapLintError as error:
|
||||
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user