mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
31 Commits
v1.0.13
...
sun/doubao
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ea222ba7 | ||
|
|
d8e08736f1 | ||
|
|
138a2ef785 | ||
|
|
0cb6cdf818 | ||
|
|
5d9b3d305f | ||
|
|
9229c50fcf | ||
|
|
d25f79bb64 | ||
|
|
4d84994ce6 | ||
|
|
6b56e0fdde | ||
|
|
1262aac480 | ||
|
|
abb02cd46c | ||
|
|
db7d3cb64d | ||
|
|
5134719da9 | ||
|
|
5a0e1d3dd9 | ||
|
|
09e60eeaf4 | ||
|
|
4f90fd3b77 | ||
|
|
6212513c43 | ||
|
|
e8df0ea63e | ||
|
|
6d0d687be2 | ||
|
|
148a04a7f8 | ||
|
|
ba19bd9f93 | ||
|
|
830fb3bbe5 | ||
|
|
1ad7cfab5b | ||
|
|
5280517d4b | ||
|
|
3ad6f2fac4 | ||
|
|
be79485fe3 | ||
|
|
94bba91224 | ||
|
|
0d50616e77 | ||
|
|
d5784eac28 | ||
|
|
663c24aadf | ||
|
|
6ad25cd452 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -153,14 +153,14 @@ jobs:
|
||||
run: |
|
||||
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
||||
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
||||
|
||||
# Analyze base branch via worktree
|
||||
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
||||
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
||||
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
||||
@@ -209,6 +209,7 @@ jobs:
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ tests/mail/reports/
|
||||
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add email priority support for compose and read (#538)
|
||||
- **mail**: Support scheduled send (#534)
|
||||
- **drive**: Support sheet cell comments in `+add-comment` (#518)
|
||||
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
|
||||
- **base**: Auto grant current user for bot create and copy (#497)
|
||||
- **base**: Add identity priority strategy and error handling (#505)
|
||||
- **auth**: Improve login scope handling and messages (#523)
|
||||
- Add OKR business domain (#522)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
|
||||
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
|
||||
|
||||
## [v1.0.13] - 2026-04-16
|
||||
|
||||
### Features
|
||||
@@ -382,6 +404,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
|
||||
@@ -30,7 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 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 |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
@@ -38,6 +38,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -200,7 +201,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
|
||||
lark-cli docs +create --doc-format markdown --content "<title>Weekly Report</title>\n# Progress\n- Completed feature X"
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -201,7 +202,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
|
||||
lark-cli docs +create --doc-format markdown --content "<title>周报</title>\n# 本周进展\n- 完成了 X 功能"
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
@@ -96,10 +96,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type loginMsg struct {
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
@@ -58,9 +59,10 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
AuthSuccess: "授权已完成,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常:以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
@@ -93,9 +95,10 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
AuthSuccess: "Authorization completed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
|
||||
@@ -69,6 +69,12 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// AuthorizedUser should contain two %s placeholders (userName, openId)
|
||||
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
|
||||
if got == msg.AuthorizedUser {
|
||||
t.Errorf("%s AuthorizedUser has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
@@ -190,11 +190,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
if msg.AuthorizedUser != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Message: "授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -376,8 +376,8 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
@@ -392,15 +392,15 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
if strings.Contains(got, "授权成功") {
|
||||
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -469,7 +469,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
@@ -619,8 +619,8 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
@@ -634,6 +634,9 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "OK: 授权成功") {
|
||||
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
@@ -743,7 +746,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"OK: 授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -771,7 +774,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
|
||||
115
cmd/build.go
Normal file
115
cmd/build.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// BuildOption configures optional aspects of the command tree construction.
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used.
|
||||
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
c.streams = &cmdutil.IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
|
||||
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.keychain = kc
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
|
||||
_, rootCmd := buildInternal(ctx, inv, opts...)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is the internal constructor that also returns Factory for error handling.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
|
||||
cfg := &buildConfig{
|
||||
streams: cmdutil.SystemIO(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(cfg)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
Long: rootLong,
|
||||
Version: build.Version,
|
||||
}
|
||||
|
||||
rootCmd.SetContext(ctx)
|
||||
rootCmd.SetIn(cfg.streams.In)
|
||||
rootCmd.SetOut(cfg.streams.Out)
|
||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
return f, rootCmd
|
||||
}
|
||||
18
cmd/init.go
Normal file
18
cmd/init.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/larksuite/cli/internal/vfs"
|
||||
|
||||
// SetDefaultFS replaces the global filesystem implementation used by internal
|
||||
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
|
||||
// the default OS filesystem is restored.
|
||||
//
|
||||
// Call this before Build or Execute to take effect.
|
||||
func SetDefaultFS(fs vfs.FS) {
|
||||
if fs == nil {
|
||||
fs = vfs.OsFs{}
|
||||
}
|
||||
vfs.DefaultFS = fs
|
||||
}
|
||||
49
cmd/root.go
49
cmd/root.go
@@ -14,15 +14,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -30,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -95,38 +85,9 @@ func Execute() int {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
f := cmdutil.NewDefault(inv)
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
Long: rootLong,
|
||||
Version: build.Version,
|
||||
}
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
f, rootCmd := buildInternal(context.Background(), inv)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
@@ -190,6 +151,12 @@ func isCompletionCommand(args []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
|
||||
@@ -135,7 +135,7 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
f := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
|
||||
@@ -196,3 +196,28 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDisabled bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, true},
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
// SchemaOptions holds all inputs for the schema command.
|
||||
type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
@@ -41,7 +44,7 @@ func printServices(w io.Writer) {
|
||||
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
|
||||
}
|
||||
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
@@ -55,9 +58,13 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
for _, resName := range sortedKeys(resources) {
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
resMap, _ := resources[resName].(map[string]interface{})
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
if len(methods) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
for _, methodName := range sortedKeys(methods) {
|
||||
m, _ := methods[methodName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
@@ -359,6 +366,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -369,7 +377,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
@@ -451,6 +459,7 @@ func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]s
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
@@ -469,9 +478,9 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec)
|
||||
printResourceList(out, spec, mode)
|
||||
} else {
|
||||
output.PrintJson(out, spec)
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -492,6 +501,7 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
@@ -500,13 +510,26 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||
if mode.IsActive() {
|
||||
filtered := make(map[string]interface{})
|
||||
for k, v := range resource {
|
||||
filtered[k] = v
|
||||
}
|
||||
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||
}
|
||||
output.PrintJson(out, filtered)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var mNames []string
|
||||
@@ -525,3 +548,67 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() {
|
||||
return spec
|
||||
}
|
||||
result := make(map[string]interface{}, len(spec))
|
||||
for k, v := range spec {
|
||||
result[k] = v
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return result
|
||||
}
|
||||
filteredRes := make(map[string]interface{}, len(resources))
|
||||
for resName, resVal := range resources {
|
||||
resMap, ok := resVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
filtered := filterMethodsByStrictMode(methods, mode)
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
resCopy := make(map[string]interface{}, len(resMap))
|
||||
for k, v := range resMap {
|
||||
resCopy[k] = v
|
||||
}
|
||||
resCopy["methods"] = filtered
|
||||
filteredRes[resName] = resCopy
|
||||
}
|
||||
result["resources"] = filteredRes
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
|
||||
// Returns the original map unmodified when strict mode is off.
|
||||
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() || methods == nil {
|
||||
return methods
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
filtered := make(map[string]interface{}, len(methods))
|
||||
for name, val := range methods {
|
||||
m, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tokens, _ := m["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
filtered[name] = val
|
||||
continue
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == token {
|
||||
filtered[name] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -177,11 +177,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
|
||||
37
internal/cmdutil/completion.go
Normal file
37
internal/cmdutil/completion.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Cobra keeps completion callbacks in a package-global map keyed by
|
||||
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
|
||||
// outlive the command itself. Skip registration when the current invocation
|
||||
// will not serve a completion request.
|
||||
var flagCompletionsDisabled atomic.Bool
|
||||
|
||||
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
|
||||
// registering and no-op. Typically set once at process start.
|
||||
func SetFlagCompletionsDisabled(disabled bool) {
|
||||
flagCompletionsDisabled.Store(disabled)
|
||||
}
|
||||
|
||||
// FlagCompletionsDisabled reports the current switch state.
|
||||
func FlagCompletionsDisabled() bool {
|
||||
return flagCompletionsDisabled.Load()
|
||||
}
|
||||
|
||||
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
|
||||
// and honors the package switch. The underlying error is swallowed to match
|
||||
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
|
||||
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
|
||||
if flagCompletionsDisabled.Load() {
|
||||
return
|
||||
}
|
||||
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
|
||||
}
|
||||
78
internal/cmdutil/completion_test.go
Normal file
78
internal/cmdutil/completion_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected default false")
|
||||
}
|
||||
SetFlagCompletionsDisabled(true)
|
||||
if !FlagCompletionsDisabled() {
|
||||
t.Fatal("expected true after Set(true)")
|
||||
}
|
||||
SetFlagCompletionsDisabled(false)
|
||||
if FlagCompletionsDisabled() {
|
||||
t.Fatal("expected false after Set(false)")
|
||||
}
|
||||
}
|
||||
|
||||
// When disabled, a *cobra.Command must be collectable after the caller drops
|
||||
// its reference — i.e. the wrapper did not touch cobra's global map.
|
||||
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(true)
|
||||
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
|
||||
|
||||
const N = 5
|
||||
var collected atomic.Int32
|
||||
func() {
|
||||
for range N {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
|
||||
}
|
||||
}()
|
||||
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
|
||||
for range 30 {
|
||||
runtime.GC()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got := collected.Load(); int(got) != N {
|
||||
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
|
||||
}
|
||||
}
|
||||
|
||||
// When enabled, the registered completion must be reachable via cobra.
|
||||
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
|
||||
SetFlagCompletionsDisabled(false)
|
||||
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("foo", "", "")
|
||||
want := []cobra.Completion{"a", "b"}
|
||||
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
return want, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
fn, ok := cmd.GetFlagCompletionFunc("foo")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func to be registered")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("unexpected completion result: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -34,27 +32,26 @@ import (
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
func NewDefault(inv InvocationContext) *Factory {
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
if streams == nil {
|
||||
streams = SystemIO()
|
||||
}
|
||||
f := &Factory{
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
}
|
||||
f.IOStreams = &IOStreams{
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
ErrOut: os.Stderr,
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
IOStreams: streams,
|
||||
}
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: f.Keychain,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
@@ -93,9 +90,9 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var transport http.RoundTripper = util.NewBaseTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
@@ -122,7 +119,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -142,7 +139,7 @@ func buildSDKTransport() http.RoundTripper {
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain keychain.KeychainAccess
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "target"})
|
||||
f := NewDefault(nil, InvocationContext{Profile: "target"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "missing"})
|
||||
f := NewDefault(nil, InvocationContext{Profile: "missing"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
@@ -164,7 +164,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T)
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -189,7 +189,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -217,7 +217,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
|
||||
fileio.Register(provider)
|
||||
t.Cleanup(func() { fileio.Register(prev) })
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
f := NewDefault(nil, InvocationContext{})
|
||||
if f.FileIOProvider != provider {
|
||||
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -28,7 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -36,7 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc()
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
// Add top-level JSON keys as text form fields.
|
||||
if m, ok := dataJSON.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
fd.AddField(k, fmt.Sprintf("%v", v))
|
||||
fd.AddField(k, formatFormFieldValue(v))
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
|
||||
// field string. float64 is handled specially: fmt's default %v/%g switches to
|
||||
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
|
||||
// backends reject when parsing the field as an integer. Use decimal notation
|
||||
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
|
||||
// All other types fall through to %v.
|
||||
func formatFormFieldValue(v any) string {
|
||||
if n, ok := v.(float64); ok {
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
@@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
|
||||
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
|
||||
// float64 delegates to %g which switches to scientific notation at ~1e6
|
||||
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
|
||||
// integer reject that, surfacing as a generic "params error".
|
||||
func TestFormatFormFieldValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
|
||||
{"float64 below scientific threshold", float64(358934), "358934"},
|
||||
{"float64 zero", float64(0), "0"},
|
||||
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
|
||||
{"float64 negative", float64(-42), "-42"},
|
||||
{"float64 fractional preserved", float64(3.14), "3.14"},
|
||||
{"string pass-through", "hello", "hello"},
|
||||
{"bool true", true, "true"},
|
||||
{"int via %v", 42, "42"},
|
||||
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatFormFieldValue(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "io"
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// IOStreams provides the standard input/output/error streams.
|
||||
// Commands should use these instead of os.Stdin/Stdout/Stderr
|
||||
@@ -14,3 +19,13 @@ type IOStreams struct {
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
func SystemIO() *IOStreams {
|
||||
return &IOStreams{
|
||||
In: os.Stdin, //nolint:forbidigo // entry point for real stdio
|
||||
Out: os.Stdout, //nolint:forbidigo // entry point for real stdio
|
||||
ErrOut: os.Stderr, //nolint:forbidigo // entry point for real stdio
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), //nolint:forbidigo // need Fd() for terminal check
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@ import (
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
type DefaultAccountProvider struct {
|
||||
keychain keychain.KeychainAccess
|
||||
keychain func() keychain.KeychainAccess
|
||||
profile string
|
||||
}
|
||||
|
||||
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
if kc == nil {
|
||||
kc = keychain.Default
|
||||
}
|
||||
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
||||
}
|
||||
|
||||
@@ -36,7 +39,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type noopKC struct{}
|
||||
@@ -99,7 +100,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
|
||||
}
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
)
|
||||
|
||||
@@ -15,6 +16,29 @@ import (
|
||||
var knownArrayFields = []string{
|
||||
"items", "files", "events", "rooms", "records", "nodes",
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
"chats", "messages", "tasks", "created_tasks",
|
||||
}
|
||||
|
||||
// asGenericSlice converts any slice value into []interface{}.
|
||||
// Returns the slice and true when v is a slice, regardless of element type
|
||||
// ([]interface{}, []map[string]interface{}, []MyStruct, etc.). This keeps
|
||||
// formatter logic working when business code uses typed slices.
|
||||
func asGenericSlice(v interface{}) ([]interface{}, bool) {
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
if s, ok := v.([]interface{}); ok {
|
||||
return s, true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Slice {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]interface{}, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
out[i] = rv.Index(i).Interface()
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
@@ -23,7 +47,7 @@ var knownArrayFields = []string{
|
||||
func FindArrayField(data map[string]interface{}) string {
|
||||
for _, name := range knownArrayFields {
|
||||
if arr, ok := data[name]; ok {
|
||||
if _, isArr := arr.([]interface{}); isArr {
|
||||
if _, isArr := asGenericSlice(arr); isArr {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -31,7 +55,7 @@ func FindArrayField(data map[string]interface{}) string {
|
||||
// Fallback: lexicographically first array field (deterministic)
|
||||
var candidates []string
|
||||
for k, v := range data {
|
||||
if _, isArr := v.([]interface{}); isArr {
|
||||
if _, isArr := asGenericSlice(v); isArr {
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
}
|
||||
@@ -68,11 +92,12 @@ func toGeneric(v interface{}) interface{} {
|
||||
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
|
||||
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
|
||||
//
|
||||
// If data is already a plain []interface{}, it is returned as-is.
|
||||
// If data is already a slice, it is returned as a []interface{}. Typed slices
|
||||
// such as []map[string]interface{} are also accepted via asGenericSlice.
|
||||
func ExtractItems(data interface{}) []interface{} {
|
||||
resultMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
if arr, ok := asGenericSlice(data); ok {
|
||||
return arr
|
||||
}
|
||||
return nil
|
||||
@@ -81,7 +106,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 1: Lark API envelope — result["data"][arrayField]
|
||||
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
|
||||
if field := FindArrayField(dataObj); field != "" {
|
||||
if items, ok := dataObj[field].([]interface{}); ok {
|
||||
if items, ok := asGenericSlice(dataObj[field]); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -90,7 +115,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 2: direct map — result[arrayField]
|
||||
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
|
||||
if field := FindArrayField(resultMap); field != "" {
|
||||
if items, ok := resultMap[field].([]interface{}); ok {
|
||||
if items, ok := asGenericSlice(resultMap[field]); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,113 @@ func TestExtractItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: shortcuts often collect results into typed slices like
|
||||
// []map[string]interface{} instead of []interface{}. ExtractItems must
|
||||
// recognise those so --format table/csv/ndjson render the array rather
|
||||
// than falling back to a key/value view of the envelope.
|
||||
func TestExtractItems_TypedSlice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data interface{}
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "direct map with []map[string]interface{} under known field",
|
||||
data: map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_a", "name": "Alice"},
|
||||
{"chat_id": "oc_b", "name": "Bob"},
|
||||
},
|
||||
"has_more": true,
|
||||
"total": float64(2),
|
||||
},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "envelope with []map[string]interface{} under data.messages",
|
||||
data: map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"messages": []map[string]interface{}{
|
||||
{"message_id": "om_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "direct map with []map[string]interface{} under created_tasks",
|
||||
data: map[string]interface{}{
|
||||
"created_tasks": []map[string]interface{}{
|
||||
{"task_id": "t1"},
|
||||
{"task_id": "t2"},
|
||||
{"task_id": "t3"},
|
||||
},
|
||||
},
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "typed slice of structs via fallback",
|
||||
data: map[string]interface{}{
|
||||
"widgets": []struct {
|
||||
Name string `json:"name"`
|
||||
}{{Name: "x"}, {Name: "y"}},
|
||||
},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "raw typed slice passed directly",
|
||||
data: []map[string]interface{}{
|
||||
{"k": "v"},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
items := ExtractItems(tc.data)
|
||||
if len(items) != tc.want {
|
||||
t.Fatalf("expected %d items, got %d (%v)", tc.want, len(items), items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --format table on the 7 affected shortcuts used to print
|
||||
// the envelope as a key/value table because the typed slice was ignored.
|
||||
// After the fix, the array should be expanded into a proper header row.
|
||||
func TestFormatValue_Table_TypedSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_abc", "name": "Lark test"},
|
||||
},
|
||||
"has_more": true,
|
||||
"total": float64(1),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatTable)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "chat_id") {
|
||||
t.Errorf("table output should expose chat_id column, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "oc_abc") {
|
||||
t.Errorf("table output should contain the chat row, got:\n%s", out)
|
||||
}
|
||||
// The fallback bug manifested as the envelope being rendered as rows:
|
||||
// the 'has_more' / 'total' envelope keys would appear as first-column
|
||||
// labels. A correct render puts the array's element keys in the header
|
||||
// and keeps envelope metadata out of the table body.
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "has_more") || strings.HasPrefix(trimmed, "total ") {
|
||||
t.Errorf("envelope field leaked into table body:\n%s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_LegacyFormats(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
|
||||
@@ -38,6 +38,9 @@ const (
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -73,6 +76,12 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
@@ -40,6 +40,13 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
{
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -63,5 +63,9 @@
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
|
||||
},
|
||||
"okr": {
|
||||
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
|
||||
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
70
rebase-420/dd05477.md
Normal file
70
rebase-420/dd05477.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Cherry-pick 冲突解决报告: `dd05477`
|
||||
|
||||
- **原始 commit**: `dd05477` feat: add SetDefaultFS to allow replacing the global filesystem implementation
|
||||
- **作者**: tuxedomm, 2026-04-09
|
||||
- **新 commit**: `4d84994`
|
||||
- **目标分支**: `feat/main_rebased_420`(基于 `larksuite/cli` 最新 main)
|
||||
|
||||
## 改动范围
|
||||
|
||||
10 个文件, +179 / -70:
|
||||
|
||||
- **新增**: `cmd/build.go`, `cmd/init.go`
|
||||
- **修改**: `cmd/root.go`, `cmd/root_integration_test.go`, `internal/cmdutil/factory_default.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`, `internal/credential/integration_test.go`
|
||||
|
||||
核心意图:
|
||||
- 把 `cmd.Execute()` 里的 root 命令组装逻辑抽取到新文件 `cmd/build.go` 的 `buildInternal()`, 并暴露 `Build()` 作为库入口
|
||||
- 引入 `cmd/init.go` 里的 `SetDefaultFS(fs vfs.FS)` 允许调用方在 `Build/Execute` 之前替换全局 fs
|
||||
- `cmdutil.NewDefault(inv)` 签名调整为 `NewDefault(streams *IOStreams, inv InvocationContext)`
|
||||
- `credentialDeps.Keychain` 从 `keychain.KeychainAccess` 改为 `func() keychain.KeychainAccess`(惰性读取, 允许构造后替换)
|
||||
- `cmdutil.SystemIO()` 新函数封装对真实 stdio 的引用
|
||||
|
||||
## 冲突情况
|
||||
|
||||
只有一个文件冲突: `cmd/root.go`(2 处)
|
||||
|
||||
| 位置 | HEAD(main) | fork(dd05477) |
|
||||
|---|---|---|
|
||||
| imports 段 | 保留 `cmd/api`, `cmd/auth`, `cmd/completion`, `cmdconfig`, `cmd/doctor`, `cmd/profile`, `cmd/schema`, `cmd/service`, `cmdupdate`, `shortcuts` 等 | 全部删除(这些 import 随 Execute 函数体一起搬去新文件 `cmd/build.go`)|
|
||||
| `Execute()` 函数体 | 完整包含 Factory 构造 + rootCmd 构造 + 子命令注册 + strict-mode 剪枝 | 精简为 `f, rootCmd := buildInternal(context.Background(), inv)` |
|
||||
|
||||
### 为什么会冲突
|
||||
|
||||
fork 的 dd05477 比 fork 之前落后 main 很多 commit, 而 main 上(比如 PR #391)在 fork 不知道的情况下加了 `rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))` 这一行 —— 它处于 fork 想整体搬走的那段代码里。git 无法自动判断这一行应该保留还是跟着搬, 所以报冲突。
|
||||
|
||||
## 解决方案
|
||||
|
||||
**两处冲突都采用 fork 的重构结构**(把 imports / 组装逻辑搬去 `cmd/build.go`), 但在 `cmd/build.go` 的 `buildInternal()` 里**追加**了 main 新增的 update 命令。
|
||||
|
||||
### 具体改动
|
||||
|
||||
在 `cmd/build.go` 里:
|
||||
|
||||
```go
|
||||
// imports 段补上
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
|
||||
// 在 rootCmd.AddCommand(completion.NewCmdCompletion(f)) 之后追加
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
```
|
||||
|
||||
如果不这样做, 就会丢失 main PR #391 引入的 `lark-cli update` 子命令。
|
||||
|
||||
## 非冲突文件处理
|
||||
|
||||
其余 9 个文件的 patch 全部直接应用, 无语义冲突:
|
||||
|
||||
- `cmd/build.go`, `cmd/init.go`: 新增文件
|
||||
- `cmd/root_integration_test.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/credential/integration_test.go`: 跟随签名变更调整调用方(`NewDefault(nil, ...)`、`cachedHttpClientFunc(&Factory{...})` 等)
|
||||
- `internal/cmdutil/factory_default.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`: 签名/结构体字段类型调整
|
||||
- `cmd/root.go`: 冲突段外其余部分(update 检查、错误处理等)保持原样
|
||||
|
||||
## 验证
|
||||
|
||||
- `go build ./...` 通过
|
||||
- `go test ./cmd/... ./internal/cmdutil/... ./internal/credential/...` 全部通过
|
||||
|
||||
## 依赖
|
||||
|
||||
- `internal/vfs` 包(`DefaultFS`、`OsFs`、`FS` interface)在 main 上已存在, `SetDefaultFS` 要切换的全局状态有完整基础
|
||||
- `cmdupdate` 包(main PR #391)已存在
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCopy = common.Shortcut{
|
||||
Command: "+base-copy",
|
||||
Description: "Copy a base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:copy"},
|
||||
UserScopes: []string{"base:app:copy"},
|
||||
BotScopes: []string{"base:app:copy", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCreate = common.Shortcut{
|
||||
Command: "+base-create",
|
||||
Description: "Create a new base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:create"},
|
||||
UserScopes: []string{"base:app:create"},
|
||||
BotScopes: []string{"base:app:create", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "base name", Required: true},
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -19,12 +20,16 @@ import (
|
||||
)
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
return newExecuteFactoryWithUserOpenID(t, "ou_testuser")
|
||||
}
|
||||
|
||||
func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
@@ -48,7 +53,14 @@ func withBaseWorkingDir(t *testing.T, dir string) {
|
||||
|
||||
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout)
|
||||
}
|
||||
|
||||
func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if authTypes != nil {
|
||||
shortcut.AuthTypes = authTypes
|
||||
}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
@@ -60,6 +72,14 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
@@ -68,11 +88,32 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "app_token"); got != "app_x" {
|
||||
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +138,14 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
@@ -105,14 +154,243 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"}
|
||||
if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) {
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["copied"] != true {
|
||||
t.Fatalf("copied = %#v, want true", data["copied"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "base_token"); got != "app_new" {
|
||||
t.Fatalf("base.base_token = %q, want %q", got, "app_new")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantFailureDoesNotFailCopy(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base copy should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCopy, authTypes(), []string{"+base-copy", "--base-token", "app_src", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
t.Run("create bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create user", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func decodeBaseEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
|
||||
@@ -17,36 +17,24 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
|
||||
}
|
||||
|
||||
func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if runtime.Bool("without-content") {
|
||||
body["without_content"] = true
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy").
|
||||
Body(body).
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases").
|
||||
Body(body)
|
||||
Body(buildBaseCreateBody(runtime))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
@@ -59,6 +47,28 @@ func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "copied": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, buildBaseCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "created": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
@@ -72,15 +82,10 @@ func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
@@ -88,10 +93,20 @@ func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "created": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func augmentBasePermissionGrant(runtime *common.RuntimeContext, out, base map[string]interface{}) {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, extractBasePermissionToken(base), "bitable"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
func extractBasePermissionToken(base map[string]interface{}) string {
|
||||
for _, key := range []string{"base_token", "app_token"} {
|
||||
if token := strings.TrimSpace(common.GetString(base, key)); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -488,12 +488,46 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
fmt.Fprintln(ctx.IO().Out, string(b))
|
||||
}
|
||||
|
||||
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
|
||||
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
|
||||
// that should be preserved as-is in JSON output.
|
||||
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if ctx.JqExpr != "" {
|
||||
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
if ctx.outputErr == nil {
|
||||
ctx.outputErr = err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(ctx.IO().Out)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(env)
|
||||
}
|
||||
|
||||
// OutFormat prints output based on --format flag.
|
||||
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
|
||||
// When JqExpr is set, routes through Out() regardless of format.
|
||||
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
ctx.outFormat(data, meta, prettyFn, false)
|
||||
}
|
||||
|
||||
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
|
||||
// Use this when the data contains XML/HTML content that should be preserved as-is.
|
||||
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
ctx.outFormat(data, meta, prettyFn, true)
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
|
||||
outFn := ctx.Out
|
||||
if raw {
|
||||
outFn = ctx.OutRaw
|
||||
}
|
||||
if ctx.JqExpr != "" {
|
||||
ctx.Out(data, meta)
|
||||
outFn(data, meta)
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
@@ -501,10 +535,10 @@ func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, pretty
|
||||
if prettyFn != nil {
|
||||
prettyFn(ctx.IO().Out)
|
||||
} else {
|
||||
ctx.Out(data, meta)
|
||||
outFn(data, meta)
|
||||
}
|
||||
case "json", "":
|
||||
ctx.Out(data, meta)
|
||||
outFn(data, meta)
|
||||
default:
|
||||
// table, csv, ndjson — pass data directly; FormatValue handles both
|
||||
// plain arrays and maps with array fields (e.g. {"members":[…]})
|
||||
@@ -595,6 +629,9 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
registerShortcutFlags(cmd, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
parent.AddCommand(cmd)
|
||||
if shortcut.PostMount != nil {
|
||||
shortcut.PostMount(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
@@ -860,7 +897,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
}
|
||||
if len(fl.Enum) > 0 {
|
||||
vals := fl.Enum
|
||||
_ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return vals, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
@@ -876,11 +913,11 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if s.HasFormat {
|
||||
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
98
shortcuts/common/runner_flag_completion_test.go
Normal file
98
shortcuts/common/runner_flag_completion_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestShortcutMount_FlagCompletionsRegistered exercises the two
|
||||
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
|
||||
// the per-flag enum completion (runner.go:879) and the auto-injected --format
|
||||
// completion (runner.go:895).
|
||||
func TestShortcutMount_FlagCompletionsRegistered(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(false)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
|
||||
// Enum flag completion.
|
||||
fn, ok := cmd.GetFlagCompletionFunc("sort-by")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --sort-by")
|
||||
}
|
||||
got, _ := fn(cmd, nil, "")
|
||||
if len(got) != 2 || got[0] != "asc" || got[1] != "desc" {
|
||||
t.Fatalf("sort-by completion = %v, want [asc desc]", got)
|
||||
}
|
||||
|
||||
// HasFormat-injected --format completion.
|
||||
fn, ok = cmd.GetFlagCompletionFunc("format")
|
||||
if !ok {
|
||||
t.Fatal("expected completion func for --format")
|
||||
}
|
||||
got, _ = fn(cmd, nil, "")
|
||||
want := []string{"json", "pretty", "table", "ndjson", "csv"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("format completion = %v, want %v", got, want)
|
||||
}
|
||||
for i, v := range want {
|
||||
if got[i] != v {
|
||||
t.Fatalf("format completion[%d] = %q, want %q", i, got[i], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_FlagCompletionsDisabled verifies the switch actually
|
||||
// prevents the two registrations from landing in cobra's global map.
|
||||
func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
cmdutil.SetFlagCompletionsDisabled(true)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
Description: "fetch doc",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+fetch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("sort-by"); ok {
|
||||
t.Fatal("did not expect completion func for --sort-by when disabled")
|
||||
}
|
||||
if _, ok := cmd.GetFlagCompletionFunc("format"); ok {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package common
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Flag.Input source constants.
|
||||
const (
|
||||
@@ -43,6 +47,11 @@ type Shortcut struct {
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// PostMount is an optional hook called after the cobra.Command is fully
|
||||
// configured (flags registered, tips set) but before it is added to the
|
||||
// parent. Use it to install custom help functions or tweak the command.
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
|
||||
// ScopesForIdentity returns the scopes applicable for the given identity.
|
||||
|
||||
@@ -20,6 +20,18 @@ var alignMap = map[string]int{
|
||||
"right": 3,
|
||||
}
|
||||
|
||||
// fileViewMap maps the user-facing --file-view value to the docx File block
|
||||
// `view_type` enum. The underlying values come from the open platform spec:
|
||||
//
|
||||
// 1 = card view (default)
|
||||
// 2 = preview view (renders audio/video files as an inline player)
|
||||
// 3 = inline view
|
||||
var fileViewMap = map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
|
||||
var DocMediaInsert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+media-insert",
|
||||
@@ -33,6 +45,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
{Name: "type", Default: "image", Desc: "type: image | file"},
|
||||
{Name: "align", Desc: "alignment: left | center | right"},
|
||||
{Name: "caption", Desc: "image caption text"},
|
||||
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -42,6 +55,14 @@ var DocMediaInsert = common.Shortcut{
|
||||
if docRef.Kind == "doc" {
|
||||
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
|
||||
}
|
||||
if view := runtime.Str("file-view"); view != "" {
|
||||
if _, ok := fileViewMap[view]; !ok {
|
||||
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
|
||||
}
|
||||
if runtime.Str("type") != "file" {
|
||||
return output.ErrValidation("--file-view only applies when --type=file")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,9 +76,10 @@ var DocMediaInsert = common.Shortcut{
|
||||
filePath := runtime.Str("file")
|
||||
mediaType := runtime.Str("type")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
parentType := parentTypeForMediaType(mediaType)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0, fileViewType)
|
||||
createBlockData["index"] = "<children_len>"
|
||||
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
|
||||
|
||||
@@ -92,6 +114,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
mediaType := runtime.Str("type")
|
||||
alignStr := runtime.Str("align")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
documentID, err := resolveDocxDocumentID(runtime, docInput)
|
||||
if err != nil {
|
||||
@@ -132,7 +155,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
|
||||
createData, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
|
||||
nil, buildCreateBlockData(mediaType, insertIndex))
|
||||
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,12 +231,22 @@ func parentTypeForMediaType(mediaType string) string {
|
||||
return "docx_image"
|
||||
}
|
||||
|
||||
func buildCreateBlockData(mediaType string, index int) map[string]interface{} {
|
||||
func buildCreateBlockData(mediaType string, index int, fileViewType int) map[string]interface{} {
|
||||
child := map[string]interface{}{
|
||||
"block_type": blockTypeForMediaType(mediaType),
|
||||
}
|
||||
if mediaType == "file" {
|
||||
child["file"] = map[string]interface{}{}
|
||||
fileData := map[string]interface{}{}
|
||||
// view_type can only be set at block creation time; the PATCH
|
||||
// replace_file endpoint does not accept it, so if the caller wants
|
||||
// preview/inline rendering we must wire it in here. Whitelist the
|
||||
// concrete enum values so a stray positive int cannot produce a
|
||||
// malformed payload if Validate is ever bypassed.
|
||||
switch fileViewType {
|
||||
case 1, 2, 3:
|
||||
fileData["view_type"] = fileViewType
|
||||
}
|
||||
child["file"] = fileData
|
||||
} else {
|
||||
child["image"] = map[string]interface{}{}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 3)
|
||||
got := buildCreateBlockData("image", 3, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -29,7 +35,7 @@ func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 1)
|
||||
got := buildCreateBlockData("file", 1, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -44,6 +50,113 @@ func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The `--file-view card` path sends a different request shape than
|
||||
// omitting the flag entirely: omitting produces `file: {}`, while
|
||||
// `card` produces `file: {view_type: 1}`. The two are intended to be
|
||||
// semantically equivalent at the API level, but the on-the-wire payload
|
||||
// is different and is part of the public flag contract, so pin it down.
|
||||
func TestBuildCreateBlockDataForFileWithCardView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 1) // card
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, card) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithPreviewView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 2) // preview
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithInlineView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 3) // inline
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, inline) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// view_type must never leak into non-file blocks even if the caller
|
||||
// accidentally passes a non-zero fileViewType alongside --type=image.
|
||||
func TestBuildCreateBlockDataForImageIgnoresFileViewType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 0, 2)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 27,
|
||||
"image": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(image, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileViewMapCoversDocumentedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Assert only the documented keys — leave room for future aliases
|
||||
// (e.g. a "player" synonym for preview) without breaking this test.
|
||||
want := map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
for key, expected := range want {
|
||||
got, ok := fileViewMap[key]
|
||||
if !ok {
|
||||
t.Errorf("fileViewMap missing required key %q", key)
|
||||
continue
|
||||
}
|
||||
if got != expected {
|
||||
t.Errorf("fileViewMap[%q] = %d, want %d", key, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -161,3 +274,98 @@ func TestExtractCreatedBlockTargetsForFileUsesNestedFileBlock(t *testing.T) {
|
||||
t.Fatalf("extractCreatedBlockTargets(file) replaceBlockID = %q, want %q", replaceBlockID, "file_inner")
|
||||
}
|
||||
}
|
||||
|
||||
// newMediaInsertValidateRuntime builds a bare RuntimeContext wired with
|
||||
// only the flags that DocMediaInsert.Validate reads. It exists so the
|
||||
// Validate tests below can exercise the CLI contract without going
|
||||
// through the full cobra command tree.
|
||||
func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "docs +media-insert"}
|
||||
cmd.Flags().String("doc", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("file-view", "", "")
|
||||
if err := cmd.Flags().Set("doc", doc); err != nil {
|
||||
t.Fatalf("set --doc: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", mediaType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if fileView != "" {
|
||||
if err := cmd.Flags().Set("file-view", fileView); err != nil {
|
||||
t.Fatalf("set --file-view: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// Validate is the real user-facing contract for --file-view: unknown
|
||||
// values must be rejected, and passing the flag alongside --type!=file
|
||||
// must also be rejected. buildCreateBlockData tests alone cannot catch
|
||||
// regressions here, so lock the guard logic down explicitly.
|
||||
func TestDocMediaInsertValidateFileView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaType string
|
||||
fileView string
|
||||
wantErr string // substring; empty means success expected
|
||||
}{
|
||||
{
|
||||
name: "file with card is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "card",
|
||||
},
|
||||
{
|
||||
name: "file with preview is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "preview",
|
||||
},
|
||||
{
|
||||
name: "file with inline is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "inline",
|
||||
},
|
||||
{
|
||||
name: "file without file-view is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "",
|
||||
},
|
||||
{
|
||||
name: "unknown file-view value is rejected",
|
||||
mediaType: "file",
|
||||
fileView: "bogus",
|
||||
wantErr: "invalid --file-view value",
|
||||
},
|
||||
{
|
||||
name: "file-view with image type is rejected",
|
||||
mediaType: "image",
|
||||
fileView: "preview",
|
||||
wantErr: "--file-view only applies when --type=file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, ttTemp := range tests {
|
||||
tt := ttTemp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newMediaInsertValidateRuntime(t, "doxcnValidateFileView", tt.mediaType, tt.fileView)
|
||||
err := DocMediaInsert.Validate(context.Background(), rt)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,35 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
|
||||
func v1CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title", Hidden: true},
|
||||
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
|
||||
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
|
||||
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
|
||||
|
||||
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
||||
func useV2Create(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
return runtime.Str("content") != "" ||
|
||||
runtime.Str("parent-token") != "" ||
|
||||
runtime.Str("parent-position") != ""
|
||||
}
|
||||
|
||||
var DocsCreate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+create",
|
||||
@@ -17,56 +43,85 @@ var DocsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "document title"},
|
||||
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "folder-token", Desc: "parent folder token"},
|
||||
{Name: "wiki-node", Desc: "wiki node token"},
|
||||
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"},
|
||||
},
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
},
|
||||
v1CreateFlags(),
|
||||
v2CreateFlags(),
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
count := 0
|
||||
if runtime.Str("folder-token") != "" {
|
||||
count++
|
||||
if useV2Create(runtime) {
|
||||
return validateCreateV2(ctx, runtime)
|
||||
}
|
||||
if runtime.Str("wiki-node") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-space") != "" {
|
||||
count++
|
||||
}
|
||||
if count > 1 {
|
||||
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
return validateCreateV1(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildDocsCreateArgs(runtime)
|
||||
d := common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: create-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "create-doc").Set("args", args)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
||||
if useV2Create(runtime) {
|
||||
return dryRunCreateV2(ctx, runtime)
|
||||
}
|
||||
return d
|
||||
return dryRunCreateV1(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := buildDocsCreateArgs(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
if useV2Create(runtime) {
|
||||
return executeCreateV2(ctx, runtime)
|
||||
}
|
||||
augmentDocsCreateResult(runtime, result)
|
||||
|
||||
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
return executeCreateV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
|
||||
},
|
||||
}
|
||||
|
||||
func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--markdown is required")
|
||||
}
|
||||
count := 0
|
||||
if runtime.Str("folder-token") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-node") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-space") != "" {
|
||||
count++
|
||||
}
|
||||
if count > 1 {
|
||||
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildCreateArgsV1(runtime)
|
||||
d := common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: create-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "create-doc").Set("args", args)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+create")
|
||||
args := buildCreateArgsV1(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
augmentCreateResultV1(runtime, result)
|
||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"markdown": runtime.Str("markdown"),
|
||||
}
|
||||
@@ -90,18 +145,17 @@ type docsPermissionTarget struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) {
|
||||
target := selectDocsPermissionTarget(result)
|
||||
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
||||
target := selectPermissionTarget(result)
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
||||
if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
|
||||
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
||||
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
|
||||
return ref
|
||||
}
|
||||
|
||||
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
||||
if docID != "" {
|
||||
return docsPermissionTarget{Token: docID, Type: "docx"}
|
||||
@@ -109,16 +163,14 @@ func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTar
|
||||
return docsPermissionTarget{}
|
||||
}
|
||||
|
||||
func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
|
||||
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
|
||||
if strings.TrimSpace(docURL) == "" {
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
|
||||
ref, err := parseDocumentRef(docURL)
|
||||
if err != nil {
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
|
||||
switch ref.Kind {
|
||||
case "wiki":
|
||||
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
|
||||
@@ -128,3 +180,68 @@ func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
|
||||
// whiteboard creation markdown is detected.
|
||||
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
|
||||
if !isWhiteboardCreateMarkdown(markdown) {
|
||||
return
|
||||
}
|
||||
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
|
||||
}
|
||||
|
||||
func isWhiteboardCreateMarkdown(markdown string) bool {
|
||||
lower := strings.ToLower(markdown)
|
||||
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "<whiteboard") &&
|
||||
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
|
||||
}
|
||||
|
||||
func normalizeBoardTokens(raw interface{}) []string {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return []string{}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
tokens := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
tokens = append(tokens, s)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
case string:
|
||||
if v == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{v}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ──
|
||||
|
||||
// concatFlags combines multiple flag slices into one.
|
||||
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
||||
var out []common.Flag
|
||||
for _, s := range slices {
|
||||
out = append(out, s...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
|
||||
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
|
||||
m := make(map[string]string, len(v1)+len(v2))
|
||||
for _, f := range v1 {
|
||||
m[f.Name] = "v1"
|
||||
}
|
||||
for _, f := range v2 {
|
||||
m[f.Name] = "v2"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -9,15 +9,182 @@ import (
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
// ── V2 (OpenAPI) tests ──
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"member": map[string]interface{}{
|
||||
"member_id": "ou_current_user",
|
||||
"member_type": "openid",
|
||||
"perm": "full_access",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>项目计划</title><h1>目标</h1>",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to parse permission request body: %v", err)
|
||||
}
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1 (MCP) tests ──
|
||||
|
||||
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
@@ -59,77 +226,9 @@ func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to parse permission request body: %v", err)
|
||||
}
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--markdown", "## 内容",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--markdown", "## 内容",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
@@ -164,12 +263,6 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
@@ -180,6 +273,8 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
|
||||
t.Helper()
|
||||
|
||||
@@ -193,6 +288,18 @@ func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
|
||||
payload, _ := json.Marshal(result)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -214,15 +321,7 @@ func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interfa
|
||||
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
|
||||
parent := &cobra.Command{Use: "docs"}
|
||||
DocsCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
return mountAndRunDocs(t, DocsCreate, args, f, stdout)
|
||||
}
|
||||
|
||||
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
|
||||
88
shortcuts/doc/docs_create_v2.go
Normal file
88
shortcuts/doc/docs_create_v2.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
|
||||
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("content") == "" {
|
||||
return common.FlagErrorf("--content is required")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCreateBody(runtime)
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents").
|
||||
Desc("OpenAPI: create document").
|
||||
Body(body)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildCreateBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stripBlockIDs(data)
|
||||
augmentDocsCreatePermission(runtime, data)
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"content": runtime.Str("content"),
|
||||
}
|
||||
if v := runtime.Str("parent-token"); v != "" {
|
||||
body["parent_token"] = v
|
||||
}
|
||||
if v := runtime.Str("parent-position"); v != "" {
|
||||
body["parent_position"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
// augmentDocsCreatePermission grants full_access to the current CLI user when
|
||||
// the document was created with bot identity.
|
||||
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return
|
||||
}
|
||||
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
|
||||
if docID == "" {
|
||||
return
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, docID, "docx"); grant != nil {
|
||||
data["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,45 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
|
||||
func v1FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "offset", Desc: "pagination offset", Hidden: true},
|
||||
{Name: "limit", Desc: "pagination limit", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
|
||||
|
||||
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only
|
||||
// flags with non-default values (bare "--doc xxx" stays on v1).
|
||||
func useV2Fetch(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
// --doc-format default is "xml", --detail default is "simple", --revision-id default is -1.
|
||||
// Only trigger auto-detect when a non-default value is present.
|
||||
if d := runtime.Str("detail"); d != "" && d != "simple" {
|
||||
return true
|
||||
}
|
||||
if f := runtime.Str("doc-format"); f != "" && f != "xml" {
|
||||
return true
|
||||
}
|
||||
if runtime.Int("revision-id") != -1 {
|
||||
return true
|
||||
}
|
||||
if m := runtime.Str("scope"); m != "" && m != "full" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var DocsFetch = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+fetch",
|
||||
@@ -20,66 +56,81 @@ var DocsFetch = common.Shortcut{
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "offset", Desc: "pagination offset"},
|
||||
{Name: "limit", Desc: "pagination limit"},
|
||||
},
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
},
|
||||
v1FetchFlags(),
|
||||
v2FetchFlags(),
|
||||
),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// Default to skipping embedded task detail expansion for faster +fetch output.
|
||||
"skip_task_detail": true,
|
||||
if useV2Fetch(runtime) {
|
||||
return dryRunFetchV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["offset"] = n
|
||||
}
|
||||
if v := runtime.Str("limit"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["limit"] = n
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: fetch-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "fetch-doc").Set("args", args)
|
||||
return dryRunFetchV1(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// Default to skipping embedded task detail expansion for faster +fetch output.
|
||||
"skip_task_detail": true,
|
||||
if useV2Fetch(runtime) {
|
||||
return executeFetchV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["offset"] = n
|
||||
}
|
||||
if v := runtime.Str("limit"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["limit"] = n
|
||||
}
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
result["markdown"] = fixExportedMarkdown(md)
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
if title, ok := result["title"].(string); ok && title != "" {
|
||||
fmt.Fprintf(w, "# %s\n\n", title)
|
||||
}
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
fmt.Fprintln(w, md)
|
||||
}
|
||||
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
|
||||
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
return executeFetchV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
|
||||
},
|
||||
}
|
||||
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildFetchArgsV1(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: fetch-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "fetch-doc").Set("args", args)
|
||||
}
|
||||
|
||||
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+fetch")
|
||||
args := buildFetchArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
result["markdown"] = fixExportedMarkdown(md)
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
if title, ok := result["title"].(string); ok && title != "" {
|
||||
fmt.Fprintf(w, "# %s\n\n", title)
|
||||
}
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
fmt.Fprintln(w, md)
|
||||
}
|
||||
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
|
||||
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["offset"] = n
|
||||
}
|
||||
if v := runtime.Str("limit"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["limit"] = n
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
194
shortcuts/doc/docs_fetch_v2.go
Normal file
194
shortcuts/doc/docs_fetch_v2.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
|
||||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
|
||||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
||||
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
|
||||
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
|
||||
}
|
||||
}
|
||||
|
||||
func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
|
||||
}
|
||||
body := buildFetchBody(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
POST(apiPath).
|
||||
Desc("OpenAPI: fetch document").
|
||||
Body(body).
|
||||
Set("document_id", ref.Token)
|
||||
}
|
||||
|
||||
func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateFetchDetail(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
|
||||
body := buildFetchBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", apiPath, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
if content, ok := doc["content"].(string); ok {
|
||||
fmt.Fprintln(w, content)
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
|
||||
detail := runtime.Str("detail")
|
||||
switch detail {
|
||||
case "", "simple":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
"export_block_id": false,
|
||||
"export_style_attrs": false,
|
||||
"export_cite_extra_data": false,
|
||||
}
|
||||
case "with-ids":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
}
|
||||
case "full":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
"export_style_attrs": true,
|
||||
"export_cite_extra_data": true,
|
||||
}
|
||||
}
|
||||
|
||||
if ro := buildReadOption(runtime); ro != nil {
|
||||
body["read_option"] = ro
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。
|
||||
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
ro := map[string]interface{}{"read_mode": mode}
|
||||
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
|
||||
ro["start_block_id"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
|
||||
ro["end_block_id"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(runtime.Str("keyword")); v != "" {
|
||||
ro["keyword"] = v
|
||||
}
|
||||
if v := runtime.Int("context-before"); v > 0 {
|
||||
ro["context_before"] = strconv.Itoa(v)
|
||||
}
|
||||
if v := runtime.Int("context-after"); v > 0 {
|
||||
ro["context_after"] = strconv.Itoa(v)
|
||||
}
|
||||
if v := runtime.Int("max-depth"); v >= 0 {
|
||||
ro["max_depth"] = strconv.Itoa(v)
|
||||
}
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
if format == "" || format == "xml" {
|
||||
return nil
|
||||
}
|
||||
if detail == "with-ids" || detail == "full" {
|
||||
return fmt.Errorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
|
||||
func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v := runtime.Int("context-before"); v < 0 {
|
||||
return fmt.Errorf("--context-before must be >= 0, got %d", v)
|
||||
}
|
||||
if v := runtime.Int("context-after"); v < 0 {
|
||||
return fmt.Errorf("--context-after must be >= 0, got %d", v)
|
||||
}
|
||||
if v := runtime.Int("max-depth"); v < -1 {
|
||||
return fmt.Errorf("--max-depth must be >= -1, got %d", v)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "outline":
|
||||
return nil
|
||||
case "range":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
|
||||
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
|
||||
return fmt.Errorf("range mode requires --start-block-id or --end-block-id")
|
||||
}
|
||||
return nil
|
||||
case "keyword":
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return fmt.Errorf("keyword mode requires --keyword")
|
||||
}
|
||||
return nil
|
||||
case "section":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
|
||||
return fmt.Errorf("section mode requires --start-block-id")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid --scope %q", mode)
|
||||
}
|
||||
}
|
||||
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
|
||||
runtime := newFetchBodyTestRuntime(ctx)
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newCreateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildCreateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newUpdateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildUpdateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["scene"]; ok {
|
||||
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
cmd.Flags().String("end-block-id", "", "")
|
||||
cmd.Flags().String("keyword", "", "")
|
||||
cmd.Flags().Int("context-before", 0, "")
|
||||
cmd.Flags().Int("context-after", 0, "")
|
||||
cmd.Flags().Int("max-depth", -1, "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+create"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("content", "<title>hello</title>", "")
|
||||
cmd.Flags().String("parent-token", "", "")
|
||||
cmd.Flags().String("parent-position", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+update"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", 0, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
@@ -5,12 +5,13 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var validModes = map[string]bool{
|
||||
var validModesV1 = map[string]bool{
|
||||
"append": true,
|
||||
"overwrite": true,
|
||||
"replace_range": true,
|
||||
@@ -20,7 +21,7 @@ var validModes = map[string]bool{
|
||||
"delete_range": true,
|
||||
}
|
||||
|
||||
var needsSelection = map[string]bool{
|
||||
var needsSelectionV1 = map[string]bool{
|
||||
"replace_range": true,
|
||||
"replace_all": true,
|
||||
"insert_before": true,
|
||||
@@ -28,6 +29,32 @@ var needsSelection = map[string]bool{
|
||||
"delete_range": true,
|
||||
}
|
||||
|
||||
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
|
||||
func v1UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
|
||||
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
|
||||
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
|
||||
{Name: "new-title", Desc: "also update document title", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
|
||||
|
||||
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
||||
func useV2Update(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
return runtime.Str("command") != "" ||
|
||||
runtime.Str("content") != "" ||
|
||||
runtime.Str("pattern") != "" ||
|
||||
runtime.Str("block-id") != "" ||
|
||||
runtime.Str("src-block-ids") != ""
|
||||
}
|
||||
|
||||
var DocsUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+update",
|
||||
@@ -35,124 +62,104 @@ var DocsUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true},
|
||||
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"},
|
||||
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"},
|
||||
{Name: "new-title", Desc: "also update document title"},
|
||||
},
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
},
|
||||
v1UpdateFlags(),
|
||||
v2UpdateFlags(),
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
mode := runtime.Str("mode")
|
||||
if !validModes[mode] {
|
||||
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
|
||||
if useV2Update(runtime) {
|
||||
return validateUpdateV2(ctx, runtime)
|
||||
}
|
||||
|
||||
if mode != "delete_range" && runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--%s mode requires --markdown", mode)
|
||||
}
|
||||
|
||||
selEllipsis := runtime.Str("selection-with-ellipsis")
|
||||
selTitle := runtime.Str("selection-by-title")
|
||||
if selEllipsis != "" && selTitle != "" {
|
||||
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
|
||||
}
|
||||
|
||||
if needsSelection[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
}
|
||||
|
||||
return nil
|
||||
return validateUpdateV1(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"mode": runtime.Str("mode"),
|
||||
if useV2Update(runtime) {
|
||||
return dryRunUpdateV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("markdown"); v != "" {
|
||||
args["markdown"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
||||
args["selection_with_ellipsis"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-by-title"); v != "" {
|
||||
args["selection_by_title"] = v
|
||||
}
|
||||
if v := runtime.Str("new-title"); v != "" {
|
||||
args["new_title"] = v
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: update-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "update-doc").Set("args", args)
|
||||
return dryRunUpdateV1(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"mode": runtime.Str("mode"),
|
||||
if useV2Update(runtime) {
|
||||
return executeUpdateV2(ctx, runtime)
|
||||
}
|
||||
if v := runtime.Str("markdown"); v != "" {
|
||||
args["markdown"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
||||
args["selection_with_ellipsis"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-by-title"); v != "" {
|
||||
args["selection_by_title"] = v
|
||||
}
|
||||
if v := runtime.Str("new-title"); v != "" {
|
||||
args["new_title"] = v
|
||||
}
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
return executeUpdateV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
|
||||
},
|
||||
}
|
||||
|
||||
func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) {
|
||||
if !isWhiteboardCreateMarkdown(markdown) {
|
||||
return
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
mode := runtime.Str("mode")
|
||||
if mode == "" {
|
||||
return common.FlagErrorf("--mode is required")
|
||||
}
|
||||
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
|
||||
if !validModesV1[mode] {
|
||||
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
|
||||
}
|
||||
|
||||
if mode != "delete_range" && runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--%s mode requires --markdown", mode)
|
||||
}
|
||||
|
||||
selEllipsis := runtime.Str("selection-with-ellipsis")
|
||||
selTitle := runtime.Str("selection-by-title")
|
||||
if selEllipsis != "" && selTitle != "" {
|
||||
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
|
||||
}
|
||||
|
||||
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWhiteboardCreateMarkdown(markdown string) bool {
|
||||
lower := strings.ToLower(markdown)
|
||||
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "<whiteboard") &&
|
||||
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
|
||||
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: update-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "update-doc").Set("args", args)
|
||||
}
|
||||
|
||||
func normalizeBoardTokens(raw interface{}) []string {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return []string{}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
tokens := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
tokens = append(tokens, s)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
case string:
|
||||
if v == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{v}
|
||||
default:
|
||||
return []string{}
|
||||
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+update")
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"mode": runtime.Str("mode"),
|
||||
}
|
||||
if v := runtime.Str("markdown"); v != "" {
|
||||
args["markdown"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
||||
args["selection_with_ellipsis"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-by-title"); v != "" {
|
||||
args["selection_by_title"] = v
|
||||
}
|
||||
if v := runtime.Str("new-title"); v != "" {
|
||||
args["new_title"] = v
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -7,6 +7,32 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── V2 tests ──
|
||||
|
||||
func TestValidCommandsV2(t *testing.T) {
|
||||
expected := map[string]bool{
|
||||
"str_replace": true,
|
||||
"str_delete": true,
|
||||
"block_delete": true,
|
||||
"block_insert_after": true,
|
||||
"block_copy_insert_after": true,
|
||||
"block_replace": true,
|
||||
"block_move_after": true,
|
||||
"overwrite": true,
|
||||
"append": true,
|
||||
}
|
||||
if len(validCommandsV2) != len(expected) {
|
||||
t.Fatalf("expected %d commands, got %d", len(expected), len(validCommandsV2))
|
||||
}
|
||||
for cmd := range validCommandsV2 {
|
||||
if !expected[cmd] {
|
||||
t.Fatalf("unexpected command %q in validCommandsV2", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1 tests ──
|
||||
|
||||
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
t.Run("blank whiteboard tags", func(t *testing.T) {
|
||||
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
|
||||
@@ -30,13 +56,13 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeDocsUpdateResult(t *testing.T) {
|
||||
func TestNormalizeWhiteboardResult(t *testing.T) {
|
||||
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
|
||||
got, ok := result["board_tokens"].([]string)
|
||||
if !ok {
|
||||
@@ -52,7 +78,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
|
||||
"board_tokens": []interface{}{"board_1", "board_2"},
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
|
||||
want := []string{"board_1", "board_2"}
|
||||
got, ok := result["board_tokens"].([]string)
|
||||
@@ -69,7 +95,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
|
||||
"success": true,
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, "## plain text")
|
||||
normalizeWhiteboardResult(result, "## plain text")
|
||||
|
||||
if _, ok := result["board_tokens"]; ok {
|
||||
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")
|
||||
|
||||
174
shortcuts/doc/docs_update_v2.go
Normal file
174
shortcuts/doc/docs_update_v2.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var validCommandsV2 = map[string]bool{
|
||||
"str_replace": true,
|
||||
"str_delete": true,
|
||||
"block_delete": true,
|
||||
"block_insert_after": true,
|
||||
"block_copy_insert_after": true,
|
||||
"block_replace": true,
|
||||
"block_move_after": true,
|
||||
"overwrite": true,
|
||||
"append": true,
|
||||
}
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "command", Desc: "operation: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "regex pattern for str_replace / str_delete", Hidden: true},
|
||||
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
|
||||
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
|
||||
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
}
|
||||
}
|
||||
|
||||
func validCommandsV2Keys() []string {
|
||||
return []string{"str_replace", "str_delete", "block_delete", "block_insert_after", "block_copy_insert_after", "block_replace", "block_move_after", "overwrite", "append"}
|
||||
}
|
||||
|
||||
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
cmd := runtime.Str("command")
|
||||
if cmd == "" {
|
||||
return common.FlagErrorf("--command is required")
|
||||
}
|
||||
if !validCommandsV2[cmd] {
|
||||
return common.FlagErrorf("invalid --command %q, valid: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
|
||||
}
|
||||
content := runtime.Str("content")
|
||||
pattern := runtime.Str("pattern")
|
||||
blockID := runtime.Str("block-id")
|
||||
srcBlockIDs := runtime.Str("src-block-ids")
|
||||
|
||||
switch cmd {
|
||||
case "str_replace":
|
||||
if pattern == "" {
|
||||
return common.FlagErrorf("--command str_replace requires --pattern")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command str_replace requires --content")
|
||||
}
|
||||
case "str_delete":
|
||||
if pattern == "" {
|
||||
return common.FlagErrorf("--command str_delete requires --pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_delete requires --block-id")
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_insert_after requires --block-id")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command block_insert_after requires --content")
|
||||
}
|
||||
case "block_copy_insert_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
|
||||
}
|
||||
if srcBlockIDs == "" {
|
||||
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
|
||||
}
|
||||
case "block_move_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_move_after requires --block-id")
|
||||
}
|
||||
if content == "" && srcBlockIDs == "" {
|
||||
return common.FlagErrorf("--command block_move_after requires --content or --src-block-ids")
|
||||
}
|
||||
case "block_replace":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_replace requires --block-id")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command block_replace requires --content")
|
||||
}
|
||||
case "overwrite":
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command overwrite requires --content")
|
||||
}
|
||||
case "append":
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command append requires --content")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
|
||||
}
|
||||
body := buildUpdateBody(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
Desc("OpenAPI: update document").
|
||||
Body(body).
|
||||
Set("document_id", ref.Token)
|
||||
}
|
||||
|
||||
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body := buildUpdateBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "PUT", apiPath, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
cmd := runtime.Str("command")
|
||||
|
||||
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
|
||||
blockID := runtime.Str("block-id")
|
||||
if cmd == "append" {
|
||||
cmd = "block_insert_after"
|
||||
blockID = "-1"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"command": cmd,
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v != 0 {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
if v := runtime.Str("content"); v != "" {
|
||||
body["content"] = v
|
||||
}
|
||||
if v := runtime.Str("pattern"); v != "" {
|
||||
body["pattern"] = v
|
||||
}
|
||||
if blockID != "" {
|
||||
body["block_id"] = blockID
|
||||
}
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
body["src_block_ids"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
@@ -4,12 +4,18 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
|
||||
// scene without exposing it as a user-controlled CLI flag.
|
||||
const docsSceneContextKey = "lark_cli_docs_scene"
|
||||
|
||||
type documentRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -56,6 +62,40 @@ func extractDocumentToken(raw, marker string) (string, bool) {
|
||||
return token, true
|
||||
}
|
||||
|
||||
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
|
||||
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
|
||||
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
|
||||
return runtime.DoAPIJSON(method, apiPath, nil, body)
|
||||
}
|
||||
|
||||
func docsSceneFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
scene, _ := ctx.Value(docsSceneContextKey).(string)
|
||||
return strings.TrimSpace(scene)
|
||||
}
|
||||
|
||||
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
|
||||
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
|
||||
body["scene"] = scene
|
||||
}
|
||||
}
|
||||
|
||||
// stripBlockIDs removes "block_id" from each entry in data.document.newblocks.
|
||||
func stripBlockIDs(data map[string]interface{}) {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return
|
||||
}
|
||||
blocks, _ := doc["newblocks"].([]interface{})
|
||||
for _, b := range blocks {
|
||||
if m, ok := b.(map[string]interface{}); ok {
|
||||
delete(m, "block_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveRouteExtra(docID string) (string, error) {
|
||||
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
|
||||
if err != nil {
|
||||
|
||||
52
shortcuts/doc/versioned_help.go
Normal file
52
shortcuts/doc/versioned_help.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// installVersionedHelp sets a custom help function on cmd that shows only the
|
||||
// flags relevant to the selected --api-version. flagVersions maps flag name to
|
||||
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
|
||||
// always visible.
|
||||
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
|
||||
origHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
ver, _ := cmd.Flags().GetString("api-version")
|
||||
if ver == "" {
|
||||
ver = defaultVersion
|
||||
}
|
||||
// Show/hide flags based on the active version.
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if fv, ok := flagVersions[f.Name]; ok {
|
||||
f.Hidden = fv != ver
|
||||
}
|
||||
})
|
||||
origHelp(cmd, args)
|
||||
if ver == "v1" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
|
||||
" Use --api-version v2 for the latest API:\n"+
|
||||
" %s %s --api-version v2 --help\n"+
|
||||
" Upgrade skill:\n"+
|
||||
" npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
|
||||
cmd.Parent().Name(), cmd.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
|
||||
// path is used, guiding users to upgrade their skill to v2.
|
||||
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n"+
|
||||
"Please upgrade your skill: npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
|
||||
shortcut)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -63,7 +64,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)",
|
||||
Description: "Add a full-document or local comment to doc/docx/sheet, also supports wiki URL resolving to doc/docx/sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"docx:document:readonly",
|
||||
@@ -72,14 +73,15 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true},
|
||||
{Name: "doc", Desc: "document URL/token, sheet URL, or wiki URL that resolves to doc/docx/sheet", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, sheet (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,6 +90,21 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
if _, err := parseSheetCellRef(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if strings.TrimSpace(selection) != "" && blockID != "" {
|
||||
@@ -99,37 +116,69 @@ var DriveAddComment = common.Shortcut{
|
||||
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment")
|
||||
return output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
replyElements, _ := parseCommentReplyElements(runtime.Str("content"))
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
|
||||
// For wiki URLs, resolve the actual target type via API so dry-run
|
||||
// matches real execution behavior instead of guessing from --block-id.
|
||||
resolvedKind := docRef.Kind
|
||||
resolvedToken := docRef.Token
|
||||
isWiki := false
|
||||
if docRef.Kind == "wiki" {
|
||||
isWiki = true
|
||||
target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), commentModeFull)
|
||||
if err == nil {
|
||||
resolvedKind = target.FileType
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
if anchor == nil {
|
||||
anchor = &sheetAnchor{SheetID: "<sheetId>", Col: 0, Row: 0}
|
||||
}
|
||||
commentBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
desc := "1-step request: create sheet comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create sheet comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Doc/docx comment dry-run.
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
|
||||
targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode)
|
||||
|
||||
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
|
||||
commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements)
|
||||
commentBody := buildCommentCreateV2Request(resolvedKind, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements)
|
||||
commentBody = buildCommentCreateV2Request(resolvedKind, anchorBlockIDForDryRun(blockID), replyElements, nil)
|
||||
}
|
||||
|
||||
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
switch {
|
||||
case mode == commentModeFull && resolvedBy == "wiki":
|
||||
case mode == commentModeFull && isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create full comment")
|
||||
case mode == commentModeFull:
|
||||
dry.Desc("1-step request: create full comment")
|
||||
case resolvedBy == "wiki" && strings.TrimSpace(selection) != "":
|
||||
case isWiki && strings.TrimSpace(selection) != "":
|
||||
dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment")
|
||||
case resolvedBy == "wiki":
|
||||
case isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create local comment")
|
||||
case strings.TrimSpace(selection) != "":
|
||||
dry.Desc("2-step orchestration: locate block -> create local comment")
|
||||
@@ -137,19 +186,17 @@ var DriveAddComment = common.Shortcut{
|
||||
dry.Desc("1-step request: create local comment with explicit block ID")
|
||||
}
|
||||
|
||||
if resolvedBy == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to target document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
|
||||
if mode == commentModeLocal && strings.TrimSpace(selection) != "" {
|
||||
step := "[1]"
|
||||
if resolvedBy == "wiki" {
|
||||
if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
docID := resolvedToken
|
||||
if isWiki && resolvedToken == docRef.Token {
|
||||
docID = "<resolved_docx_token>"
|
||||
}
|
||||
mcpArgs := map[string]interface{}{
|
||||
"doc_id": dryRunLocateDocRef(docRef),
|
||||
"doc_id": docID,
|
||||
"limit": defaultLocateDocLimit,
|
||||
"selection_with_ellipsis": selection,
|
||||
}
|
||||
@@ -171,23 +218,29 @@ var DriveAddComment = common.Shortcut{
|
||||
if mode == commentModeLocal {
|
||||
createDesc = "Create local comment"
|
||||
step = "[2]"
|
||||
if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" {
|
||||
if isWiki && strings.TrimSpace(selection) != "" {
|
||||
step = "[3]"
|
||||
} else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" {
|
||||
} else if isWiki || strings.TrimSpace(selection) != "" {
|
||||
step = "[2]"
|
||||
} else {
|
||||
step = "[1]"
|
||||
}
|
||||
} else if resolvedBy == "wiki" {
|
||||
} else if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
|
||||
return dry.POST(createPath).
|
||||
Desc(step+" "+createDesc).
|
||||
Body(commentBody).
|
||||
Set("file_token", targetToken)
|
||||
Set("file_token", resolvedToken)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
@@ -197,6 +250,11 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Wiki resolved to sheet: redirect to sheet comment path.
|
||||
if target.FileType == "sheet" {
|
||||
return executeSheetComment(runtime, commentDocRef{Kind: "sheet", Token: target.FileToken})
|
||||
}
|
||||
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,9 +283,9 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements)
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements)
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements, nil)
|
||||
}
|
||||
|
||||
if mode == commentModeLocal {
|
||||
@@ -288,7 +346,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
|
||||
return commentModeLocal
|
||||
}
|
||||
|
||||
func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
|
||||
@@ -297,6 +355,9 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/wiki/"); ok {
|
||||
return commentDocRef{Kind: "wiki", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/docx/"); ok {
|
||||
return commentDocRef{Kind: "docx", Token: token}, nil
|
||||
}
|
||||
@@ -304,40 +365,29 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx URL, a docx token, or a wiki URL that resolves to doc/docx", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet", raw)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
}
|
||||
|
||||
return commentDocRef{Kind: "docx", Token: raw}, nil
|
||||
}
|
||||
|
||||
func dryRunResolvedCommentTarget(docRef commentDocRef, mode commentMode) (token, fileType, resolvedBy string) {
|
||||
switch docRef.Kind {
|
||||
case "docx":
|
||||
return docRef.Token, "docx", "docx"
|
||||
case "doc":
|
||||
return docRef.Token, "doc", "doc"
|
||||
case "wiki":
|
||||
if mode == commentModeFull {
|
||||
return "<resolved_file_token>", "<resolved_file_type>", "wiki"
|
||||
}
|
||||
return "<resolved_docx_token>", "docx", "wiki"
|
||||
default:
|
||||
return "<resolved_docx_token>", "docx", "docx"
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet)")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
|
||||
func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, input string, mode commentMode) (resolvedCommentTarget, error) {
|
||||
docRef, err := parseCommentDocRef(input)
|
||||
docRef, err := parseCommentDocRef(input, runtime.Str("type"))
|
||||
if err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents")
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
return resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
@@ -364,11 +414,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "" || objToken == "" {
|
||||
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "sheet",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx and sheet; for sheet use --block-id <sheetId>!<cell>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -531,12 +592,24 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
return replyElements, nil
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} {
|
||||
type sheetAnchor struct {
|
||||
SheetID string
|
||||
Col int
|
||||
Row int
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"reply_elements": replyElements,
|
||||
}
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
if sheet != nil {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": sheet.SheetID,
|
||||
"sheet_col": sheet.Col,
|
||||
"sheet_row": sheet.Row,
|
||||
}
|
||||
} else if strings.TrimSpace(blockID) != "" {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": blockID,
|
||||
}
|
||||
@@ -551,13 +624,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func dryRunLocateDocRef(docRef commentDocRef) string {
|
||||
if docRef.Kind == "wiki" {
|
||||
return "<resolved_docx_token>"
|
||||
}
|
||||
return docRef.Token
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -576,6 +642,83 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSheetCellRef parses "<sheetId>!<cell>" (e.g. "a281f9!D6") into a sheetAnchor.
|
||||
// Column is converted from letter to 0-based index (A=0), row from 1-based to 0-based.
|
||||
func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
parts := strings.SplitN(input, "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
|
||||
}
|
||||
sheetID := parts[0]
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
|
||||
// Parse cell reference like "D6" into col letter + row number.
|
||||
i := 0
|
||||
for i < len(cell) && ((cell[i] >= 'A' && cell[i] <= 'Z') || (cell[i] >= 'a' && cell[i] <= 'z')) {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(cell) {
|
||||
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
|
||||
}
|
||||
colStr := strings.ToUpper(cell[:i])
|
||||
rowStr := cell[i:]
|
||||
|
||||
// Column letter to 0-based index: A=0, B=1, ..., Z=25, AA=26.
|
||||
col := 0
|
||||
for _, ch := range colStr {
|
||||
col = col*26 + int(ch-'A'+1)
|
||||
}
|
||||
col-- // convert to 0-based
|
||||
|
||||
row, err := strconv.Atoi(rowStr)
|
||||
if err != nil || row < 1 {
|
||||
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
|
||||
}
|
||||
row-- // convert to 0-based
|
||||
|
||||
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
|
||||
}
|
||||
|
||||
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
anchor, err := parseSheetCellRef(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(docRef.Token))
|
||||
requestBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n",
|
||||
common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row)
|
||||
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"comment_id": data["comment_id"],
|
||||
"file_token": docRef.Token,
|
||||
"file_type": "sheet",
|
||||
"comment_mode": "sheet",
|
||||
"block_id": blockID,
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractURLToken(raw, marker string) (string, bool) {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -6,6 +6,9 @@ package drive
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseCommentDocRef(t *testing.T) {
|
||||
@@ -14,6 +17,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
docType string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
@@ -31,11 +35,31 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token treated as docx",
|
||||
name: "raw token with type docx",
|
||||
input: "xxxxxx",
|
||||
docType: "docx",
|
||||
wantKind: "docx",
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token with type sheet",
|
||||
input: "shtToken",
|
||||
docType: "sheet",
|
||||
wantKind: "sheet",
|
||||
wantToken: "shtToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type doc",
|
||||
input: "docToken",
|
||||
docType: "doc",
|
||||
wantKind: "doc",
|
||||
wantToken: "docToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
wantErr: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "old doc url",
|
||||
input: "https://example.larksuite.com/doc/xxxxxx",
|
||||
@@ -53,7 +77,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentDocRef(tt.input)
|
||||
got, err := parseCommentDocRef(tt.input, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
@@ -249,7 +273,7 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
|
||||
"text": "全文评论",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -279,7 +303,7 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
"text": "评论内容",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -300,3 +324,540 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
t.Fatalf("unexpected reply element: %#v", gotReplyElements[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment tests ─────────────────────────────────────────────────────
|
||||
|
||||
func TestParseCommentDocRefSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefSheetWithQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123?sheet=abc", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "请修正此单元格"},
|
||||
}
|
||||
got := buildCommentCreateV2Request("sheet", "", replyElements, &sheetAnchor{
|
||||
SheetID: "abc123",
|
||||
Col: 3,
|
||||
Row: 5,
|
||||
})
|
||||
|
||||
if got["file_type"] != "sheet" {
|
||||
t.Fatalf("expected file_type sheet, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "abc123" {
|
||||
t.Fatalf("expected block_id abc123, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["sheet_col"] != 3 {
|
||||
t.Fatalf("expected sheet_col 3, got %#v", anchor["sheet_col"])
|
||||
}
|
||||
if anchor["sheet_row"] != 5 {
|
||||
t.Fatalf("expected sheet_row 5, got %#v", anchor["sheet_row"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "test"},
|
||||
}
|
||||
// When both sheet anchor and blockID are provided, sheet anchor wins.
|
||||
got := buildCommentCreateV2Request("sheet", "should_be_ignored", replyElements, &sheetAnchor{
|
||||
SheetID: "s1",
|
||||
Col: 0,
|
||||
Row: 0,
|
||||
})
|
||||
anchor := got["anchor"].(map[string]interface{})
|
||||
if anchor["block_id"] != "s1" {
|
||||
t.Fatalf("expected sheet anchor block_id, got %#v", anchor["block_id"])
|
||||
}
|
||||
if _, exists := anchor["sheet_col"]; !exists {
|
||||
t.Fatal("expected sheet_col in anchor")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
sheetID string
|
||||
col int
|
||||
row int
|
||||
}{
|
||||
{"A1", "s1!A1", "s1", 0, 0},
|
||||
{"D6", "abc!D6", "abc", 3, 5},
|
||||
{"AA1", "s1!AA1", "s1", 26, 0},
|
||||
{"lowercase", "s1!d6", "s1", 3, 5},
|
||||
{"B10", "sheet1!B10", "sheet1", 1, 9},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseSheetCellRef(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SheetID != tc.sheetID || got.Col != tc.col || got.Row != tc.row {
|
||||
t.Fatalf("expected {%s %d %d}, got {%s %d %d}", tc.sheetID, tc.col, tc.row, got.SheetID, got.Col, got.Row)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{"", "noExclamation", "s1!", "!A1", "s1!123", "s1!A"}
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef(input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment validate tests ────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "no-exclamation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<sheetId>!<cell>") {
|
||||
t.Fatalf("expected format error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--full-comment",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--selection-with-ellipsis", "something",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment execute tests ─────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "comment123", "created_at": 1700000000},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"请检查"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "comment123") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtFromURL/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "c456"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtFromURL?sheet=abc",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "abc!A1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiResolve(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiSheetComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"wiki sheet comment"}]`,
|
||||
"--block-id", "s1!B3",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiSheetComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun coverage ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDryRunSheetDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSheet(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "sheet", "obj_token": "shtResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToDocxFull(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxLocalWithBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "local comment") {
|
||||
t.Fatalf("dry-run output missing local comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveCommentTarget coverage ───────────────────────────────────────────
|
||||
|
||||
func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/docxResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiDocxComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiDocxComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiIncompleteNodeData(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete node error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/doc/oldDocToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx and sheet") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Additional unit function tests ──────────────────────────────────────────
|
||||
|
||||
func TestAnchorBlockIDForDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := anchorBlockIDForDryRun("blk_123"); got != "blk_123" {
|
||||
t.Fatalf("expected blk_123, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(""); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(" "); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder for whitespace, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefRowZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef("s1!A0")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be >= 1") {
|
||||
t.Fatalf("expected row validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefPathLikeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseCommentDocRef("token/with/slash", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --doc input") {
|
||||
t.Fatalf("expected unsupported doc error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractURLTokenEmptyAfterMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := extractURLToken("https://example.com/sheets/", "/sheets/")
|
||||
if ok {
|
||||
t.Fatal("expected false for empty token after marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Status: 400, Body: map[string]interface{}{"code": 1061002, "msg": "params error"},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,4 +707,29 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Fatalf("ImChatMessageList.DryRun().Format() = %s", formatted)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatMessageList dry run includes root-only query", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"page-size": "20",
|
||||
"sort": "desc",
|
||||
}, nil)
|
||||
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
|
||||
if !strings.Contains(formatted, "only_thread_root_messages=true") {
|
||||
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"page-size": "20",
|
||||
"sort": "desc",
|
||||
}, nil)
|
||||
|
||||
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
|
||||
if !strings.Contains(formatted, "only_thread_root_messages=true") {
|
||||
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +152,9 @@ func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]in
|
||||
// This API has lighter permission requirements and works with user identity
|
||||
// even when the target user is not in the app's visible range.
|
||||
// Response uses "users" (not "items") and "user_id" (not "open_id").
|
||||
// The basic_batch endpoint caps user_ids at 10 per request.
|
||||
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
|
||||
const batchSize = 50
|
||||
const batchSize = 10
|
||||
for i := 0; i < len(missingIDs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(missingIDs) {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -170,6 +172,57 @@ func TestResolveSenderNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
|
||||
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
|
||||
// expect three requests with sizes 10 / 10 / 5.
|
||||
var batchSizes []int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
|
||||
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs, _ := payload["user_ids"].([]interface{})
|
||||
if len(userIDs) > 10 {
|
||||
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
|
||||
}
|
||||
batchSizes = append(batchSizes, len(userIDs))
|
||||
|
||||
users := make([]interface{}, 0, len(userIDs))
|
||||
for _, raw := range userIDs {
|
||||
id, _ := raw.(string)
|
||||
users = append(users, map[string]interface{}{
|
||||
"user_id": id,
|
||||
"name": "name-" + id,
|
||||
})
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"users": users},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
missingIDs := make([]string, 25)
|
||||
for i := range missingIDs {
|
||||
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
|
||||
}
|
||||
nameMap := map[string]string{}
|
||||
batchResolveByBasicContact(runtime, missingIDs, nameMap)
|
||||
|
||||
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
|
||||
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
|
||||
}
|
||||
if len(nameMap) != 25 {
|
||||
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSenderNamesAPIFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
|
||||
@@ -210,14 +210,15 @@ func TestBuildChatMessageListRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
want := larkcore.QueryParams{
|
||||
"container_id_type": {"chat"},
|
||||
"container_id": {"oc_123"},
|
||||
"sort_type": {"ByCreateTimeAsc"},
|
||||
"page_size": {"50"},
|
||||
"card_msg_content_type": {"raw_card_content"},
|
||||
"start_time": {"1772294400"},
|
||||
"end_time": {"1772467199"},
|
||||
"page_token": {"next"},
|
||||
"container_id_type": {"chat"},
|
||||
"container_id": {"oc_123"},
|
||||
"sort_type": {"ByCreateTimeAsc"},
|
||||
"page_size": {"50"},
|
||||
"only_thread_root_messages": {"true"},
|
||||
"card_msg_content_type": {"raw_card_content"},
|
||||
"start_time": {"1772294400"},
|
||||
"end_time": {"1772467199"},
|
||||
"page_token": {"next"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildChatMessageListRequest() = %#v, want %#v", got, want)
|
||||
@@ -245,6 +246,13 @@ func TestBuildChatMessageListRequest(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) {
|
||||
got := buildChatMessageListParams("desc", "20", "oc_123")
|
||||
if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) {
|
||||
t.Fatalf("only_thread_root_messages = %#v, want true", vals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChatIDForMessagesList(t *testing.T) {
|
||||
t.Run("chat passthrough", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
|
||||
@@ -172,11 +172,12 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
|
||||
pageSize = min(max(n, 1), 50)
|
||||
}
|
||||
return larkcore.QueryParams{
|
||||
"container_id_type": []string{"chat"},
|
||||
"container_id": []string{chatId},
|
||||
"sort_type": []string{sortType},
|
||||
"page_size": []string{strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
"container_id_type": []string{"chat"},
|
||||
"container_id": []string{chatId},
|
||||
"sort_type": []string{sortType},
|
||||
"page_size": []string{strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
"only_thread_root_messages": []string{"true"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,12 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st
|
||||
return err
|
||||
}
|
||||
|
||||
func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string]interface{}, error) {
|
||||
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil)
|
||||
func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) {
|
||||
var bodyParams map[string]interface{}
|
||||
if sendTime != "" {
|
||||
bodyParams = map[string]interface{}{"send_time": sendTime}
|
||||
}
|
||||
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
|
||||
}
|
||||
|
||||
func extractDraftID(data map[string]interface{}) string {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -1162,6 +1163,7 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
|
||||
out["date_formatted"] = normalized.DateFormatted
|
||||
out["message_state_text"] = normalized.MessageStateText
|
||||
if normalized.PriorityType != "" {
|
||||
out["priority_type"] = normalized.PriorityType
|
||||
out["priority_type_text"] = normalized.PriorityTypeText
|
||||
}
|
||||
out["body_plain_text"] = normalized.BodyPlainText
|
||||
@@ -1240,11 +1242,22 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
|
||||
out.MessageStateText = messageStateText(state)
|
||||
out.FolderID = strVal(msg["folder_id"])
|
||||
out.LabelIDs = toStringList(msg["label_ids"])
|
||||
// Priority: prefer label_ids (HIGH_PRIORITY/LOW_PRIORITY), fall back to priority_type field.
|
||||
priorityType := strVal(msg["priority_type"])
|
||||
out.PriorityType = priorityType
|
||||
if priorityType != "" {
|
||||
out.PriorityTypeText = priorityTypeText(priorityType)
|
||||
}
|
||||
for _, label := range out.LabelIDs {
|
||||
switch label {
|
||||
case "HIGH_PRIORITY":
|
||||
out.PriorityType = "1"
|
||||
out.PriorityTypeText = "high"
|
||||
case "LOW_PRIORITY":
|
||||
out.PriorityType = "5"
|
||||
out.PriorityTypeText = "low"
|
||||
}
|
||||
}
|
||||
if securityLevel := toSecurityLevel(msg["security_level"]); securityLevel != nil {
|
||||
out.SecurityLevel = securityLevel
|
||||
}
|
||||
@@ -1707,6 +1720,48 @@ func priorityTypeText(priorityType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// priorityFlag is the common flag definition for --priority, shared by all compose shortcuts.
|
||||
var priorityFlag = common.Flag{
|
||||
Name: "priority",
|
||||
Desc: "Email priority: high, normal, low. If omitted, no priority header is set.",
|
||||
}
|
||||
|
||||
// parsePriority parses the --priority flag value and returns the X-Cli-Priority
|
||||
// header value. Returns "" if the priority should not be set (empty or "normal").
|
||||
func parsePriority(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return "", nil
|
||||
case "high":
|
||||
return "1", nil
|
||||
case "normal":
|
||||
return "", nil
|
||||
case "low":
|
||||
return "5", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --priority value %q: expected high, normal, or low", value)
|
||||
}
|
||||
}
|
||||
|
||||
// validatePriorityFlag validates the --priority flag value in Validate, so invalid
|
||||
// values are caught before Execute (and before dry-run prints an API plan).
|
||||
func validatePriorityFlag(runtime *common.RuntimeContext) error {
|
||||
v := runtime.Str("priority")
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := parsePriority(v)
|
||||
return err
|
||||
}
|
||||
|
||||
// applyPriority sets the X-Cli-Priority header on the EML builder if priority is non-empty.
|
||||
func applyPriority(bld emlbuilder.Builder, priority string) emlbuilder.Builder {
|
||||
if priority == "" {
|
||||
return bld
|
||||
}
|
||||
return bld.Header("X-Cli-Priority", priority)
|
||||
}
|
||||
|
||||
// parseNetAddrs converts a comma-separated address string to []net/mail.Address.
|
||||
// It reuses ParseMailboxList for display-name-aware parsing and deduplicates
|
||||
// by email address (case-insensitive), preserving the first occurrence.
|
||||
@@ -1906,6 +1961,27 @@ func checkAttachmentSizeLimit(fio fileio.FileIO, filePaths []string, extraBytes
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
|
||||
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
|
||||
func validateSendTime(runtime *common.RuntimeContext) error {
|
||||
sendTime := runtime.Str("send-time")
|
||||
if sendTime == "" {
|
||||
return nil
|
||||
}
|
||||
if !runtime.Bool("confirm-send") {
|
||||
return fmt.Errorf("--send-time requires --confirm-send to be set")
|
||||
}
|
||||
ts, err := strconv.ParseInt(sendTime, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
|
||||
}
|
||||
minTime := time.Now().Unix() + 5*60
|
||||
if ts < minTime {
|
||||
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfirmSendScope checks that the user's token includes the
|
||||
// mail:user_mailbox.message:send scope when --confirm-send is set.
|
||||
// This scope is not declared in the shortcut's static Scopes (to keep the
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -1006,3 +1008,212 @@ func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateSendTime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("send-time", "", "")
|
||||
cmd.Flags().Bool("confirm-send", false, "")
|
||||
if sendTime != "" {
|
||||
_ = cmd.Flags().Set("send-time", sendTime)
|
||||
}
|
||||
if confirmSend {
|
||||
_ = cmd.Flags().Set("confirm-send", "true")
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_Empty(t *testing.T) {
|
||||
rt := newSendTimeRuntime(t, "", false)
|
||||
if err := validateSendTime(rt); err != nil {
|
||||
t.Fatalf("expected nil when send-time is empty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_RequiresConfirmSend(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
rt := newSendTimeRuntime(t, future, false)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is set without --confirm-send")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--confirm-send") {
|
||||
t.Errorf("expected error to mention --confirm-send, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_InvalidInteger(t *testing.T) {
|
||||
rt := newSendTimeRuntime(t, "not-a-number", true)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is not a valid integer")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unix timestamp") {
|
||||
t.Errorf("expected error to mention Unix timestamp, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_TooSoon(t *testing.T) {
|
||||
// Just 1 minute in the future — below the 5-minute minimum.
|
||||
soon := strconv.FormatInt(time.Now().Unix()+60, 10)
|
||||
rt := newSendTimeRuntime(t, soon, true)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is less than 5 minutes in the future")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected error to mention 5 minute minimum, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_Valid(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
rt := newSendTimeRuntime(t, future, true)
|
||||
if err := validateSendTime(rt); err != nil {
|
||||
t.Fatalf("expected nil for valid future send-time, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"high", "high", "1", false},
|
||||
{"normal", "normal", "", false},
|
||||
{"low", "low", "5", false},
|
||||
{"case-insensitive HIGH", "HIGH", "1", false},
|
||||
{"whitespace padding", " low ", "5", false},
|
||||
{"invalid", "urgent", "", true},
|
||||
{"numeric not accepted", "1", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parsePriority(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parsePriority(%q): expected error, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parsePriority(%q): unexpected error: %v", tc.input, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("parsePriority(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
labels []interface{}
|
||||
priorityType string
|
||||
wantType string
|
||||
wantText string
|
||||
}{
|
||||
{"high from label", []interface{}{"UNREAD", "HIGH_PRIORITY"}, "", "1", "high"},
|
||||
{"low from label", []interface{}{"LOW_PRIORITY"}, "", "5", "low"},
|
||||
{"no priority label", []interface{}{"UNREAD"}, "", "", ""},
|
||||
{"label overrides priority_type field", []interface{}{"HIGH_PRIORITY"}, "5", "1", "high"},
|
||||
{"priority_type fallback when no label", []interface{}{"UNREAD"}, "1", "1", "high"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
msg := map[string]interface{}{
|
||||
"message_id": "m1",
|
||||
"label_ids": tc.labels,
|
||||
}
|
||||
if tc.priorityType != "" {
|
||||
msg["priority_type"] = tc.priorityType
|
||||
}
|
||||
out := buildMessageOutput(msg, false)
|
||||
gotText, _ := out["priority_type_text"].(string)
|
||||
if gotText != tc.wantText {
|
||||
t.Errorf("priority_type_text = %q, want %q", gotText, tc.wantText)
|
||||
}
|
||||
gotType, _ := out["priority_type"].(string)
|
||||
if gotType != tc.wantType {
|
||||
t.Errorf("priority_type = %q, want %q", gotType, tc.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPriority(t *testing.T) {
|
||||
// Empty priority: EML must not contain X-Cli-Priority header.
|
||||
emptyBld := emlbuilder.New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("no priority").
|
||||
TextBody([]byte("body"))
|
||||
emptyBld = applyPriority(emptyBld, "")
|
||||
raw, err := emptyBld.BuildBase64URL()
|
||||
if err != nil {
|
||||
t.Fatalf("build EML failed: %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(raw)
|
||||
if strings.Contains(eml, "X-Cli-Priority") {
|
||||
t.Errorf("expected no X-Cli-Priority header when priority is empty, got EML:\n%s", eml)
|
||||
}
|
||||
|
||||
// Non-empty priority: header must be present with the exact value.
|
||||
highBld := emlbuilder.New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("high priority").
|
||||
TextBody([]byte("body"))
|
||||
highBld = applyPriority(highBld, "1")
|
||||
raw, err = highBld.BuildBase64URL()
|
||||
if err != nil {
|
||||
t.Fatalf("build EML failed: %v", err)
|
||||
}
|
||||
eml = decodeBase64URL(raw)
|
||||
if !strings.Contains(eml, "X-Cli-Priority: 1") {
|
||||
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePriorityFlag(t *testing.T) {
|
||||
makeRuntime := func(priority string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("priority", "", "")
|
||||
if priority != "" {
|
||||
_ = cmd.Flags().Set("priority", priority)
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
priority string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty ok", "", false},
|
||||
{"high ok", "high", false},
|
||||
{"normal ok", "normal", false},
|
||||
{"low ok", "low", false},
|
||||
{"invalid urgent", "urgent", true},
|
||||
{"invalid numeric", "1", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validatePriorityFlag(makeRuntime(tc.priority))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("validatePriorityFlag(%q): expected error, got nil", tc.priority)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("validatePriorityFlag(%q): unexpected error: %v", tc.priority, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
|
||||
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
@@ -79,19 +80,23 @@ var MailDraftCreate = common.Shortcut{
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult)
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult, priority)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -129,7 +134,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult) (string, error) {
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult, priority string) (string, error) {
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
if senderEmail == "" {
|
||||
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
@@ -190,6 +195,7 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(input.Attach), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
@@ -142,6 +142,40 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "priority test",
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(rawEML)
|
||||
if !strings.Contains(eml, "X-Cli-Priority: 1") {
|
||||
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "no priority",
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(rawEML)
|
||||
if strings.Contains(eml, "X-Cli-Priority") {
|
||||
t.Errorf("expected no X-Cli-Priority header when priority is empty, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
@@ -153,7 +187,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
{Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
|
||||
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
|
||||
{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
|
||||
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -276,6 +277,19 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
setRecipients("cc", runtime.Str("set-cc"))
|
||||
setRecipients("bcc", runtime.Str("set-bcc"))
|
||||
|
||||
// --set-priority → inject set_header / remove_header op
|
||||
if setPriority := runtime.Str("set-priority"); setPriority != "" {
|
||||
headerVal, pErr := parsePriority(setPriority)
|
||||
if pErr != nil {
|
||||
return patch, pErr
|
||||
}
|
||||
if headerVal != "" {
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_header", Name: "X-Cli-Priority", Value: headerVal})
|
||||
} else {
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_header", Name: "X-Cli-Priority"})
|
||||
}
|
||||
}
|
||||
|
||||
if len(patch.Ops) == 0 {
|
||||
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
|
||||
}
|
||||
|
||||
92
shortcuts/mail/mail_draft_edit_test.go
Normal file
92
shortcuts/mail/mail_draft_edit_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newDraftEditRuntime creates a minimal RuntimeContext with the draft-edit
|
||||
// flags used by buildDraftEditPatch.
|
||||
func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{
|
||||
"set-subject", "set-to", "set-cc", "set-bcc",
|
||||
"set-priority", "patch-file",
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name, val := range flags {
|
||||
_ = cmd.Flags().Set(name, val)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityHigh(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "high"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
||||
}
|
||||
op := patch.Ops[0]
|
||||
if op.Op != "set_header" {
|
||||
t.Errorf("Op = %q, want set_header", op.Op)
|
||||
}
|
||||
if op.Name != "X-Cli-Priority" {
|
||||
t.Errorf("Name = %q, want X-Cli-Priority", op.Name)
|
||||
}
|
||||
if op.Value != "1" {
|
||||
t.Errorf("Value = %q, want 1", op.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityLow(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "low"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 || patch.Ops[0].Value != "5" {
|
||||
t.Fatalf("expected single set_header with value 5, got %+v", patch.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityNormalClears(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "normal"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
||||
}
|
||||
if patch.Ops[0].Op != "remove_header" || patch.Ops[0].Name != "X-Cli-Priority" {
|
||||
t.Errorf("expected remove_header X-Cli-Priority, got %+v", patch.Ops[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "urgent"})
|
||||
if _, err := buildDraftEditPatch(rt); err == nil {
|
||||
t.Fatal("expected error for invalid --set-priority value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Only the set_subject op should be present; no priority op injected.
|
||||
if len(patch.Ops) != 1 || patch.Ops[0].Op != "set_subject" {
|
||||
t.Errorf("expected single set_subject op, got %+v", patch.Ops)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ var MailForward = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
to := runtime.Str("to")
|
||||
@@ -59,6 +61,9 @@ var MailForward = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("confirm-send") {
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
@@ -67,7 +72,10 @@ var MailForward = common.Shortcut{
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -79,6 +87,12 @@ var MailForward = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
@@ -169,6 +183,7 @@ var MailForward = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body)))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
// Download original attachments and accumulate size for limit check
|
||||
type downloadedAtt struct {
|
||||
content []byte
|
||||
@@ -231,7 +246,7 @@ var MailForward = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ var MailReply = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -56,10 +58,16 @@ var MailReply = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -71,6 +79,12 @@ var MailReply = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
@@ -170,6 +184,7 @@ var MailReply = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -194,7 +209,7 @@ var MailReply = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -57,10 +59,16 @@ var MailReplyAll = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -73,6 +81,12 @@ var MailReplyAll = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
@@ -184,6 +198,7 @@ var MailReplyAll = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -208,7 +223,7 @@ var MailReplyAll = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ var MailSend = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
to := runtime.Str("to")
|
||||
subject := runtime.Str("subject")
|
||||
@@ -62,9 +64,15 @@ var MailSend = common.Shortcut{
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePriorityFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -77,9 +85,14 @@ var MailSend = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
signatureID := runtime.Str("signature-id")
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
|
||||
@@ -136,6 +149,7 @@ var MailSend = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(body))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -162,7 +176,7 @@ var MailSend = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
135
shortcuts/mail/mail_send_time_integration_test.go
Normal file
135
shortcuts/mail/mail_send_time_integration_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// mailShortcutTestFactoryWithSendScope mirrors mailShortcutTestFactory but
|
||||
// additionally grants the mail:user_mailbox.message:send scope so tests can
|
||||
// exercise code paths guarded by validateConfirmSendScope (e.g. validateSendTime).
|
||||
func mailShortcutTestFactoryWithSendScope(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
cfg := mailTestConfig()
|
||||
token := &auth.StoredUAToken{
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AppId: cfg.AppID,
|
||||
AccessToken: "test-user-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
})
|
||||
return cmdutil.TestFactory(t, cfg)
|
||||
}
|
||||
|
||||
// tooSoonSendTime returns a send-time 60s in the future — below the 5-minute
|
||||
// floor enforced by validateSendTime.
|
||||
func tooSoonSendTime() string {
|
||||
return strconv.FormatInt(time.Now().Unix()+60, 10)
|
||||
}
|
||||
|
||||
// futureSendTime returns a send-time 10 minutes in the future — above the floor.
|
||||
func futureSendTime() string {
|
||||
return strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invalid --send-time rejected by each compose shortcut
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMailSend_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailReply_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailReplyAll_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailReplyAll, []string{
|
||||
"+reply-all", "--message-id", "msg_001", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailForward_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward", "--message-id", "msg_001", "--to", "alice@example.com",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --send-time without --confirm-send is rejected up front
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMailSend_SendTimeWithoutConfirmSend(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
|
||||
"--send-time", futureSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --send-time without --confirm-send, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--confirm-send") {
|
||||
t.Errorf("expected error to mention --confirm-send, got: %v", err)
|
||||
}
|
||||
}
|
||||
101
shortcuts/okr/okr_cli_resp.go
Normal file
101
shortcuts/okr/okr_cli_resp.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
// RespAlignment 对齐关系
|
||||
type RespAlignment struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
FromOwner RespOwner `json:"from_owner"`
|
||||
ToOwner RespOwner `json:"to_owner"`
|
||||
FromEntityType string `json:"from_entity_type"`
|
||||
FromEntityID string `json:"from_entity_id"`
|
||||
ToEntityType string `json:"to_entity_type"`
|
||||
ToEntityID string `json:"to_entity_id"`
|
||||
}
|
||||
|
||||
// RespCategory 分类
|
||||
type RespCategory struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
CategoryType string `json:"category_type"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Name CategoryName `json:"name"`
|
||||
}
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
type RespIndicator struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
EntityType *string `json:"entity_type,omitempty"`
|
||||
EntityID *string `json:"entity_id,omitempty"`
|
||||
IndicatorStatus *string `json:"indicator_status,omitempty"`
|
||||
StatusCalculateType *string `json:"status_calculate_type,omitempty"`
|
||||
StartValue *float64 `json:"start_value,omitempty"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CurrentValue *float64 `json:"current_value,omitempty"`
|
||||
CurrentValueCalculateType *string `json:"current_value_calculate_type,omitempty"`
|
||||
Unit *RespIndicatorUnit `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicatorUnit 指标单位
|
||||
type RespIndicatorUnit struct {
|
||||
UnitType *string `json:"unit_type,omitempty"`
|
||||
UnitValue *string `json:"unit_value,omitempty"`
|
||||
}
|
||||
|
||||
// RespKeyResult 关键结果
|
||||
type RespKeyResult struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjective 目标
|
||||
type RespObjective struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResult `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespOwner OKR 所有者
|
||||
type RespOwner struct {
|
||||
OwnerType string `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
182
shortcuts/okr/okr_cycle_detail.go
Normal file
182
shortcuts/okr/okr_cycle_detail.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
|
||||
var OKRCycleDetail = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-detail",
|
||||
Description: "List objectives and key results under an OKR cycle",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.content:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return common.FlagErrorf("--cycle-id is required")
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--cycle-id must be a positive int64")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
params := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles/:cycle_id/objectives").
|
||||
Params(params).
|
||||
Set("cycle_id", cycleID).
|
||||
Desc("Auto-paginates objectives in the cycle, then calls GET /open-apis/okr/v2/objectives/:objective_id/key_results for each objective to fetch key results")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var objectives []Objective
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var obj Objective
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
objectives = append(objectives, obj)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := make(larkcore.QueryParams)
|
||||
krQuery.Set("page_size", "100")
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
krQuery.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
@@ -0,0 +1,561 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleDetailTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-detail-" + suffix,
|
||||
AppSecret: "secret-okr-detail-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleDetailShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRCycleDetail.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleDetailValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --cycle-id")
|
||||
}
|
||||
// cobra catches required flag before our Validate runs
|
||||
if !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_NonNumeric(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "abc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Zero(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for zero --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
// Need to register stubs because Validate passes and Execute runs
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "123"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleDetailDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{
|
||||
"+cycle-detail",
|
||||
"--cycle-id", "456",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "456") {
|
||||
t.Fatalf("dry-run output should contain cycle-id 456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/456/objectives") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleDetailExecute_NoObjectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/100/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "100"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "100" {
|
||||
t.Fatalf("cycle_id = %v, want 100", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 0 {
|
||||
t.Fatalf("objectives = %v, want empty", objs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_WithObjectivesAndKeyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// Stub for objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/200/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "200",
|
||||
"score": 0.8,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Improve team productivity",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Stub for key results of obj-1
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-1",
|
||||
"objective_id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Reduce response time by 50%",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "200"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "200" {
|
||||
t.Fatalf("cycle_id = %v, want 200", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("objectives count = %d, want 1", len(objs))
|
||||
}
|
||||
obj, _ := objs[0].(map[string]interface{})
|
||||
if obj["id"] != "obj-1" {
|
||||
t.Fatalf("objective id = %v, want obj-1", obj["id"])
|
||||
}
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 1 {
|
||||
t.Fatalf("key results count = %d, want 1", len(krs))
|
||||
}
|
||||
kr, _ := krs[0].(map[string]interface{})
|
||||
if kr["id"] != "kr-1" {
|
||||
t.Fatalf("key result id = %v, want kr-1", kr["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// First page of objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.5,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page1 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page_token",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page of objectives (no more)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.6,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page2 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p1: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-1",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.7,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p1-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p1: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-2",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.8,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p2: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-1",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.6,
|
||||
"weight": 0.4,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p2-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p2: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-2",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.6,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "300"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 2 {
|
||||
t.Fatalf("objectives count = %d, want 2", len(objs))
|
||||
}
|
||||
|
||||
// Verify key_results are aggregated across pages for each objective
|
||||
for i, objRaw := range objs {
|
||||
obj, _ := objRaw.(map[string]interface{})
|
||||
objID, _ := obj["id"].(string)
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 2 {
|
||||
t.Fatalf("objective[%d] %s: key_results count = %d, want 2", i, objID, len(krs))
|
||||
}
|
||||
// Verify KR IDs are distinct (from different pages)
|
||||
krIDs := make(map[string]bool)
|
||||
for _, krRaw := range krs {
|
||||
kr, _ := krRaw.(map[string]interface{})
|
||||
krID, _ := kr["id"].(string)
|
||||
krIDs[krID] = true
|
||||
}
|
||||
if len(krIDs) != 2 {
|
||||
t.Fatalf("objective %s: expected 2 distinct KR IDs, got %v", objID, krIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/400/objectives",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "400"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
189
shortcuts/okr/okr_cycle_list.go
Normal file
189
shortcuts/okr/okr_cycle_list.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
|
||||
// The start is the first moment of the start month; the end is the last moment of the end month.
|
||||
func parseTimeRange(s string) (start, end time.Time, err error) {
|
||||
parts := strings.SplitN(s, "--", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
|
||||
}
|
||||
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
|
||||
}
|
||||
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
|
||||
}
|
||||
// end is the last moment of the end month
|
||||
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
|
||||
if start.After(end) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// cycleOverlaps checks whether a cycle's [startMs, endMs] overlaps with [rangeStart, rangeEnd].
|
||||
func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
||||
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
Description: "List okr cycles of a certain user",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.period:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "user ID", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
}
|
||||
userID := runtime.Str("user-id")
|
||||
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := runtime.Str("time-range")
|
||||
if tr != "" {
|
||||
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseTimeRange(tr); err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id": runtime.Str("user-id"),
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles").
|
||||
Params(params).
|
||||
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
userID := runtime.Str("user-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
timeRange := runtime.Str("time-range")
|
||||
|
||||
// Parse time range for filtering
|
||||
var rangeStart, rangeEnd time.Time
|
||||
var hasRange bool
|
||||
if timeRange != "" {
|
||||
var err error
|
||||
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id", userID)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var allCycles []Cycle
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// Filter by time-range overlap
|
||||
var filtered []Cycle
|
||||
for i := range allCycles {
|
||||
if !hasRange || cycleOverlaps(&allCycles[i], rangeStart, rangeEnd) {
|
||||
filtered = append(filtered, allCycles[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
respCycles := make([]*RespCycle, 0, len(filtered))
|
||||
for i := range filtered {
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleListTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-list-" + suffix,
|
||||
AppSecret: "secret-okr-list-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleListShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRListCycles.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleListValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "invalid_type",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --user-id-type")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--user-id-type must be one of") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ControlCharsInUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-\t123",
|
||||
"--user-id-type", "open_id",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --user-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ControlCharsInTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "open_id",
|
||||
"--time-range", "2025-01\t--2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --time-range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_InvalidTimeRangeFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01-2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --time-range format")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-06--2025-01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for start after end in --time-range")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidWithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_AllUserIDTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, idType := range []string{"open_id", "union_id", "user_id"} {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "test-id",
|
||||
"--user-id-type", idType,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user-id-type=%q: unexpected error: %v", idType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleListDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-456",
|
||||
"--user-id-type", "open_id",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "ou-456") {
|
||||
t.Fatalf("dry-run output should contain user-id ou-456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-789",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
total, _ := data["total"].(float64)
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Return two cycles: one inside the range, one outside
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-in-range",
|
||||
"start_time": "1735689600000", // 2025-01-01
|
||||
"end_time": "1738368000000", // 2025-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-out-range",
|
||||
"start_time": "1704067200000", // 2024-01-01
|
||||
"end_time": "1706745600000", // 2024-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 1 {
|
||||
t.Fatalf("cycles count = %d, want 1 (only cycle-in-range should pass filter)", len(cycles))
|
||||
}
|
||||
cycle, _ := cycles[0].(map[string]interface{})
|
||||
if cycle["id"] != "cycle-in-range" {
|
||||
t.Fatalf("cycle id = %v, want cycle-in-range", cycle["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// First page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1738368000000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p2",
|
||||
"start_time": "1738368000000",
|
||||
"end_time": "1743465600000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
361
shortcuts/okr/okr_openapi.go
Normal file
361
shortcuts/okr/okr_openapi.go
Normal file
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CycleStatus 周期状态
|
||||
type CycleStatus int32
|
||||
|
||||
const (
|
||||
CycleStatusDefault CycleStatus = 0
|
||||
CycleStatusNormal CycleStatus = 1
|
||||
CycleStatusInvalid CycleStatus = 2
|
||||
CycleStatusHidden CycleStatus = 3
|
||||
)
|
||||
|
||||
func (t CycleStatus) Ptr() *CycleStatus { return &t }
|
||||
|
||||
// StatusCalculateType 状态计算类型
|
||||
type StatusCalculateType int32
|
||||
|
||||
const (
|
||||
StatusCalculateTypeManualUpdate StatusCalculateType = 0
|
||||
StatusCalculateTypeAutomaticallyUpdatesBasedOnProgressAndCurrentTime StatusCalculateType = 1
|
||||
StatusCalculateTypeStatusUpdatesBasedOnTheHighestRiskKeyResults StatusCalculateType = 2
|
||||
)
|
||||
|
||||
// BlockElementType 块元素类型
|
||||
type BlockElementType string
|
||||
|
||||
const (
|
||||
BlockElementTypeGallery BlockElementType = "gallery"
|
||||
BlockElementTypeParagraph BlockElementType = "paragraph"
|
||||
)
|
||||
|
||||
func (t BlockElementType) Ptr() *BlockElementType { return &t }
|
||||
|
||||
// CategoryName 分类名称
|
||||
type CategoryName struct {
|
||||
Zh *string `json:"zh,omitempty"`
|
||||
En *string `json:"en,omitempty"`
|
||||
Ja *string `json:"ja,omitempty"`
|
||||
}
|
||||
|
||||
// ListType 列表类型
|
||||
type ListType string
|
||||
|
||||
const (
|
||||
ListTypeBullet ListType = "bullet"
|
||||
ListTypeCheckBox ListType = "checkBox"
|
||||
ListTypeCheckedBox ListType = "checkedBox"
|
||||
ListTypeIndent ListType = "indent"
|
||||
ListTypeNumber ListType = "number"
|
||||
)
|
||||
|
||||
// OwnerType 所有者类型
|
||||
type OwnerType string
|
||||
|
||||
const (
|
||||
OwnerTypeDepartment OwnerType = "department"
|
||||
OwnerTypeUser OwnerType = "user"
|
||||
)
|
||||
|
||||
// ParagraphElementType 段落元素类型
|
||||
type ParagraphElementType string
|
||||
|
||||
const (
|
||||
ParagraphElementTypeDocsLink ParagraphElementType = "docsLink"
|
||||
ParagraphElementTypeMention ParagraphElementType = "mention"
|
||||
ParagraphElementTypeTextRun ParagraphElementType = "textRun"
|
||||
)
|
||||
|
||||
func (t ParagraphElementType) Ptr() *ParagraphElementType { return &t }
|
||||
|
||||
// ContentBlock 内容块
|
||||
type ContentBlock struct {
|
||||
Blocks []ContentBlockElement `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// ContentBlockElement 内容块元素
|
||||
type ContentBlockElement struct {
|
||||
BlockElementType *BlockElementType `json:"block_element_type,omitempty"`
|
||||
Paragraph *ContentParagraph `json:"paragraph,omitempty"`
|
||||
Gallery *ContentGallery `json:"gallery,omitempty"`
|
||||
}
|
||||
|
||||
// ContentColor 颜色
|
||||
type ContentColor struct {
|
||||
Red *int32 `json:"red,omitempty"`
|
||||
Green *int32 `json:"green,omitempty"`
|
||||
Blue *int32 `json:"blue,omitempty"`
|
||||
Alpha *float64 `json:"alpha,omitempty"`
|
||||
}
|
||||
|
||||
// ContentDocsLink 文档链接
|
||||
type ContentDocsLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// ContentGallery 图库
|
||||
type ContentGallery struct {
|
||||
Images []ContentImageItem `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ContentImageItem 图片项
|
||||
type ContentImageItem struct {
|
||||
FileToken *string `json:"file_token,omitempty"`
|
||||
Src *string `json:"src,omitempty"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// ContentLink 链接
|
||||
type ContentLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ContentList 列表
|
||||
type ContentList struct {
|
||||
ListType *ListType `json:"list_type,omitempty"`
|
||||
IndentLevel *int32 `json:"indent_level,omitempty"`
|
||||
Number *int32 `json:"number,omitempty"`
|
||||
}
|
||||
|
||||
// ContentMention 提及
|
||||
type ContentMention struct {
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraph 段落
|
||||
type ContentParagraph struct {
|
||||
Style *ContentParagraphStyle `json:"style,omitempty"`
|
||||
Elements []ContentParagraphElement `json:"elements,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphElement 段落元素
|
||||
type ContentParagraphElement struct {
|
||||
ParagraphElementType *ParagraphElementType `json:"paragraph_element_type,omitempty"`
|
||||
TextRun *ContentTextRun `json:"text_run,omitempty"`
|
||||
DocsLink *ContentDocsLink `json:"docs_link,omitempty"`
|
||||
Mention *ContentMention `json:"mention,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphStyle 段落样式
|
||||
type ContentParagraphStyle struct {
|
||||
List *ContentList `json:"list,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextRun 文本块
|
||||
type ContentTextRun struct {
|
||||
Text *string `json:"text,omitempty"`
|
||||
Style *ContentTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextStyle 文本样式
|
||||
type ContentTextStyle struct {
|
||||
Bold *bool `json:"bold,omitempty"`
|
||||
StrikeThrough *bool `json:"strike_through,omitempty"`
|
||||
BackColor *ContentColor `json:"back_color,omitempty"`
|
||||
TextColor *ContentColor `json:"text_color,omitempty"`
|
||||
Link *ContentLink `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
// Cycle 周期
|
||||
type Cycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner Owner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *CycleStatus `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// KeyResult 关键结果
|
||||
type KeyResult struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// Objective 目标
|
||||
type Objective struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *ContentBlock `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
// Owner OKR 所有者
|
||||
type Owner struct {
|
||||
OwnerType OwnerType `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ToString CycleStatus to string
|
||||
func (t CycleStatus) ToString() string {
|
||||
switch t {
|
||||
case CycleStatusDefault:
|
||||
return "default"
|
||||
case CycleStatusNormal:
|
||||
return "normal"
|
||||
case CycleStatusInvalid:
|
||||
return "invalid"
|
||||
case CycleStatusHidden:
|
||||
return "hidden"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimestamp 格式化毫秒级时间戳为 DateTime 格式
|
||||
func formatTimestamp(ts string) string {
|
||||
if ts == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return ts
|
||||
}
|
||||
t := time.UnixMilli(millis)
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// ToResp converts Cycle to RespCycle
|
||||
func (c *Cycle) ToResp() *RespCycle {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
resp.CycleStatus = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToResp converts KeyResult to RespKeyResult
|
||||
func (k *KeyResult) ToResp() *RespKeyResult {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResult{
|
||||
ID: k.ID,
|
||||
CreateTime: formatTimestamp(k.CreateTime),
|
||||
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||
Owner: *k.Owner.ToResp(),
|
||||
ObjectiveID: k.ObjectiveID,
|
||||
Position: k.Position,
|
||||
Score: k.Score,
|
||||
Weight: k.Weight,
|
||||
}
|
||||
if k.Deadline != nil {
|
||||
d := formatTimestamp(*k.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
// Serialize ContentBlock to JSON string (only if Content is not nil and has blocks)
|
||||
if k.Content != nil && len(k.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(k.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Objective to RespObjective
|
||||
func (o *Objective) ToResp() *RespObjective {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjective{
|
||||
ID: o.ID,
|
||||
CreateTime: formatTimestamp(o.CreateTime),
|
||||
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||
Owner: *o.Owner.ToResp(),
|
||||
CycleID: o.CycleID,
|
||||
Position: o.Position,
|
||||
Score: o.Score,
|
||||
Weight: o.Weight,
|
||||
CategoryID: o.CategoryID,
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
d := formatTimestamp(*o.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
// Serialize Content to JSON string
|
||||
if o.Content != nil && len(o.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
// Serialize Notes to JSON string
|
||||
if o.Notes != nil && len(o.Notes.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Notes); err == nil {
|
||||
s := string(bytes)
|
||||
result.Notes = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Owner to RespOwner
|
||||
func (o *Owner) ToResp() *RespOwner {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &RespOwner{
|
||||
OwnerType: string(o.OwnerType),
|
||||
UserID: o.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
// ptrStr dereferences a string pointer, returning "" for nil.
|
||||
func ptrStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// ptrFloat64 dereferences a float64 pointer, returning 0 for nil.
|
||||
func ptrFloat64(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
142
shortcuts/okr/okr_openapi_test.go
Normal file
142
shortcuts/okr/okr_openapi_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestFormatTimestamp(t *testing.T) {
|
||||
convey.Convey("formatTimestamp", t, func() {
|
||||
convey.Convey("empty string returns empty", func() {
|
||||
result := formatTimestamp("")
|
||||
convey.So(result, convey.ShouldEqual, "")
|
||||
})
|
||||
|
||||
convey.Convey("valid timestamp formats correctly", func() {
|
||||
result := formatTimestamp("1735689600000")
|
||||
// 不检查具体的时分秒,因为时区不同结果会不同
|
||||
convey.So(result, convey.ShouldStartWith, "2025-01-01")
|
||||
})
|
||||
|
||||
convey.Convey("invalid timestamp returns original", func() {
|
||||
result := formatTimestamp("not-a-number")
|
||||
convey.So(result, convey.ShouldEqual, "not-a-number")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestToRespMethods(t *testing.T) {
|
||||
convey.Convey("ToResp methods handle nil", t, func() {
|
||||
convey.So((*Cycle)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*KeyResult)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Objective)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Owner)(nil).ToResp(), convey.ShouldBeNil)
|
||||
})
|
||||
|
||||
convey.Convey("ToResp methods work with valid objects", t, func() {
|
||||
convey.Convey("Cycle", func() {
|
||||
cycle := &Cycle{
|
||||
ID: "cycle-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
TenantCycleID: "tenant-cycle-id",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
StartTime: "1735689600000",
|
||||
EndTime: "1751318400000",
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
Score: float64Ptr(0.75),
|
||||
}
|
||||
resp := cycle.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
obj := &Objective{
|
||||
ID: "obj-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
CycleID: "cycle-id",
|
||||
Position: int32Ptr(1),
|
||||
Score: float64Ptr(0.8),
|
||||
Weight: float64Ptr(1.0),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test objective"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resp := obj.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.8)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("KeyResult", func() {
|
||||
kr := &KeyResult{
|
||||
ID: "kr-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
ObjectiveID: "obj-id",
|
||||
Position: int32Ptr(1),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test KR"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Score: float64Ptr(0.9),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
}
|
||||
resp := kr.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "kr-id")
|
||||
convey.So(resp.ObjectiveID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.9)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// strPtr returns a pointer to the given string value.
|
||||
func strPtr(v string) *string { return &v }
|
||||
|
||||
// int32Ptr returns a pointer to the given int32 value.
|
||||
func int32Ptr(v int32) *int32 { return &v }
|
||||
|
||||
// float64Ptr returns a pointer to the given float64 value.
|
||||
func float64Ptr(v float64) *float64 { return &v }
|
||||
16
shortcuts/okr/shortcuts.go
Normal file
16
shortcuts/okr/shortcuts.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Shortcuts returns all okr shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
OKRListCycles,
|
||||
OKRCycleDetail,
|
||||
}
|
||||
}
|
||||
17
shortcuts/okr/shortcuts_test.go
Normal file
17
shortcuts/okr/shortcuts_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -45,6 +46,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
|
||||
@@ -22,7 +22,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
@@ -49,6 +49,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
normalizeBatchStyleRanges(data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
Body(map[string]interface{}{
|
||||
@@ -66,6 +67,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
@@ -81,3 +83,34 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place
|
||||
// so the /styles_batch_update endpoint accepts single-cell shorthand.
|
||||
// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to
|
||||
// "sheet1!A1:A1"; multi-cell spans pass through unchanged.
|
||||
// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be
|
||||
// expanded because the helper has no sheet-id context (the shortcut exposes
|
||||
// no --sheet-id flag), and the backend would reject the payload anyway —
|
||||
// such entries pass through unchanged. Non-string entries, missing
|
||||
// ranges keys, and non-array top-level inputs are ignored silently.
|
||||
func normalizeBatchStyleRanges(data interface{}) {
|
||||
items, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ranges, ok := entry["ranges"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, r := range ranges {
|
||||
if s, ok := r.(string); ok {
|
||||
ranges[i] = normalizePointRange("", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,46 @@ func TestSheetSetStyleExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:A1"`) {
|
||||
t.Fatalf("DryRun should expand single cell to A1:A1: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--range", "A1",
|
||||
"--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
appendStyle, _ := body["appendStyle"].(map[string]interface{})
|
||||
if appendStyle["range"] != "sheet1!A1:A1" {
|
||||
t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -523,6 +563,51 @@ func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) {
|
||||
t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"totalUpdatedCells": float64(5),
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
data, _ := body["data"].([]interface{})
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("expected 1 data entry, got %d", len(data))
|
||||
}
|
||||
entry, _ := data[0].(map[string]interface{})
|
||||
ranges, _ := entry["ranges"].([]interface{})
|
||||
if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" {
|
||||
t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -537,3 +622,101 @@ func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBatchStyleRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1", "sheet1!B2"},
|
||||
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" {
|
||||
t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-cell span passes through unchanged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1:B2"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:B2" {
|
||||
t.Fatalf("multi-cell span should be unchanged, got %v", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Without a sheetId! prefix there's no sheet context; entry is left
|
||||
// alone and the backend will reject it. Documented in the helper.
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"A1"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "A1" {
|
||||
t.Fatalf("bare single cell should pass through, got %v", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-string entries are preserved", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:A1" {
|
||||
t.Fatalf("first entry should be expanded, got %v", got[0])
|
||||
}
|
||||
if got[1] != 42 {
|
||||
t.Fatalf("int entry should be preserved, got %v", got[1])
|
||||
}
|
||||
if got[2] != nil {
|
||||
t.Fatalf("nil entry should be preserved, got %v", got[2])
|
||||
}
|
||||
if got[3] != "sheet1!B2:B2" {
|
||||
t.Fatalf("last entry should be expanded, got %v", got[3])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing or non-array ranges key is skipped", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"ranges": "not-an-array",
|
||||
},
|
||||
"not-a-map",
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
if data[1].(map[string]interface{})["ranges"] != "not-an-array" {
|
||||
t.Fatal("non-array ranges should be left alone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("top-level non-array inputs do not panic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any of these would panic if the helper didn't guard its type assertions.
|
||||
normalizeBatchStyleRanges(nil)
|
||||
normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"})
|
||||
normalizeBatchStyleRanges("string")
|
||||
normalizeBatchStyleRanges(42)
|
||||
})
|
||||
}
|
||||
|
||||
325
shortcuts/sheets/sheet_float_image.go
Normal file
325
shortcuts/sheets/sheet_float_image.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func floatImageBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func floatImageItemPath(token, sheetID, floatImageID string) string {
|
||||
return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID))
|
||||
}
|
||||
|
||||
func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != u {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func validateFloatImageRange(sheetID, rangeVal string) error {
|
||||
if rangeVal == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateSingleCellRange(rangeVal); err != nil {
|
||||
return err
|
||||
}
|
||||
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
|
||||
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageUpdatePayload rejects an update request that carries no
|
||||
// mutable field. Without this, PATCH {} reaches the server as a confusing
|
||||
// no-op or opaque error.
|
||||
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
|
||||
hasField := runtime.Str("range") != "" ||
|
||||
runtime.Cmd.Flags().Changed("width") ||
|
||||
runtime.Cmd.Flags().Changed("height") ||
|
||||
runtime.Cmd.Flags().Changed("offset-x") ||
|
||||
runtime.Cmd.Flags().Changed("offset-y")
|
||||
if !hasField {
|
||||
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageDims checks the numeric bounds we can verify without
|
||||
// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0.
|
||||
// The upper bounds (offset < anchor cell's width/height) are validated by
|
||||
// the server and surfaced through the 1310246 error hint.
|
||||
// Only flags explicitly supplied by the user are checked, so omitted flags
|
||||
// (which fall back to server defaults) pass through unchanged.
|
||||
func validateFloatImageDims(runtime *common.RuntimeContext) error {
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
if v := runtime.Int("width"); v < 20 {
|
||||
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
if v := runtime.Int("height"); v < 20 {
|
||||
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
if v := runtime.Int("offset-x"); v < 0 {
|
||||
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
if v := runtime.Int("offset-y"); v < 0 {
|
||||
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if includeToken {
|
||||
if s := runtime.Str("float-image-token"); s != "" {
|
||||
body["float_image_token"] = s
|
||||
}
|
||||
}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
body["width"] = runtime.Int("width")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
body["height"] = runtime.Int("height")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
body["offset_x"] = runtime.Int("offset-x")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
body["offset_y"] = runtime.Int("offset-y")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// SheetCreateFloatImage creates a float image on a sheet.
|
||||
var SheetCreateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-float-image",
|
||||
Description: "Create a floating image on a sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-token", Desc: "image file token (from upload API)", Required: true},
|
||||
{Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true},
|
||||
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
|
||||
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
|
||||
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFloatImageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFloatImageDims(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, true)
|
||||
if s := runtime.Str("float-image-id"); s != "" {
|
||||
body["float_image_id"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, true)
|
||||
if s := runtime.Str("float-image-id"); s != "" {
|
||||
body["float_image_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetUpdateFloatImage updates a float image's properties.
|
||||
var SheetUpdateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-float-image",
|
||||
Description: "Update a floating image",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
{Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"},
|
||||
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
|
||||
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
|
||||
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFloatImageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageUpdatePayload(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFloatImageDims(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, false)
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, false)
|
||||
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetGetFloatImage retrieves a single float image.
|
||||
var SheetGetFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-float-image",
|
||||
Description: "Get a floating image by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetListFloatImages queries all float images in a sheet.
|
||||
var SheetListFloatImages = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-float-images",
|
||||
Description: "List all floating images in a sheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetDeleteFloatImage deletes a float image.
|
||||
var SheetDeleteFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-float-image",
|
||||
Description: "Delete a floating image",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
@@ -0,0 +1,524 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── CreateFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFloatImageValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Pixel flags are int-typed by the shortcut; leave them unset (empty
|
||||
// intFlags map) so Cmd.Flags().Changed(...) returns false and
|
||||
// validateFloatImageDims doesn't try to read non-existent ints.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
}, nil, nil)
|
||||
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:B2",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "single cell") {
|
||||
t.Fatalf("expected single-cell error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-token": "boxToken", "range": "other!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
|
||||
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
intFlags map[string]int
|
||||
wantSubst string
|
||||
}{
|
||||
{"width below 20", map[string]int{"width": 5}, "--width must be >= 20"},
|
||||
{"height below 20", map[string]int{"height": 10}, "--height must be >= 20"},
|
||||
{"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"},
|
||||
{"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"},
|
||||
}
|
||||
|
||||
baseStr := map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Boundary values exactly at the lower bound should pass.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
},
|
||||
map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil)
|
||||
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("boundary values should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "",
|
||||
},
|
||||
map[string]int{"width": 200, "height": 150}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"POST"`) {
|
||||
t.Fatalf("DryRun should use POST: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `float_images`) {
|
||||
t.Fatalf("DryRun URL missing float_images: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"float_image_token":"boxToken"`) {
|
||||
t.Fatalf("DryRun missing float_image_token: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) {
|
||||
t.Fatalf("DryRun should emit numeric width/height, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{
|
||||
"float_image_id": "fi12345678", "float_image_token": "boxToken",
|
||||
"range": "sheet1!A1:A1", "width": 200, "height": 150,
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--width", "200", "--height", "150",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "float_image_id") {
|
||||
t.Fatalf("stdout missing float_image_id: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["float_image_token"] != "boxToken" {
|
||||
t.Fatalf("unexpected float_image_token: %v", body["float_image_token"])
|
||||
}
|
||||
if w, ok := body["width"].(float64); !ok || w != 200 {
|
||||
t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"])
|
||||
}
|
||||
if h, ok := body["height"].(float64); !ok || h != 150 {
|
||||
t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi12345678"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Only IDs set, no mutable field: PATCH would be an empty {} body.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
}, nil, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") {
|
||||
t.Fatalf("expected empty-payload error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any single mutable field should satisfy the payload check.
|
||||
tests := []struct {
|
||||
name string
|
||||
strFlags map[string]string
|
||||
intFlags map[string]int
|
||||
}{
|
||||
{
|
||||
name: "range only",
|
||||
strFlags: map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "sheet1!B2:B2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "offset-x only (zero value)",
|
||||
strFlags: map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
},
|
||||
intFlags: map[string]int{"offset-x": 0},
|
||||
},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
|
||||
if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "other!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "",
|
||||
}, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
|
||||
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
intFlags map[string]int
|
||||
wantSubst string
|
||||
}{
|
||||
{"width below 20", map[string]int{"width": 19}, "--width must be >= 20"},
|
||||
{"height below 20", map[string]int{"height": 0}, "--height must be >= 20"},
|
||||
{"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"},
|
||||
{"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"},
|
||||
}
|
||||
|
||||
baseStr := map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi12345678", "range": "sheet1!B2:B2",
|
||||
},
|
||||
map[string]int{"width": 300, "offset-y": 10}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PATCH"`) {
|
||||
t.Fatalf("DryRun should use PATCH: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fi12345678`) {
|
||||
t.Fatalf("DryRun missing float_image_id: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) {
|
||||
t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
|
||||
"+update-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123",
|
||||
"--width", "300", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
|
||||
"+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123",
|
||||
"--range", "sheet1!C3:C3", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetFloatImage ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetFloatImageValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1",
|
||||
}, nil)
|
||||
err := SheetGetFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fi123`) {
|
||||
t.Fatalf("DryRun missing float_image_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{
|
||||
"float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100,
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
|
||||
"+get-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fi123") {
|
||||
t.Fatalf("stdout missing fi123: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
|
||||
"+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFloatImages ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestListFloatImagesDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `float_images/query`) {
|
||||
t.Fatalf("DryRun URL missing query: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFloatImagesExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"float_image_id": "fi1"},
|
||||
map[string]interface{}{"float_image_id": "fi2"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFloatImages, []string{
|
||||
"+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fi1") {
|
||||
t.Fatalf("stdout missing fi1: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFloatImagesWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFloatImages, []string{
|
||||
"+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
|
||||
"+delete-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
|
||||
"+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
172
shortcuts/sheets/sheet_media_upload.go
Normal file
172
shortcuts/sheets/sheet_media_upload.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// sheetImageParentType is the parent_type accepted by the drive media upload
|
||||
// endpoint for media that will be anchored via +create-float-image.
|
||||
const sheetImageParentType = "sheet_image"
|
||||
|
||||
// SheetMediaUpload uploads a local image to the drive media endpoint against
|
||||
// a spreadsheet and returns the file_token. The token is usable as the
|
||||
// --float-image-token argument to +create-float-image.
|
||||
//
|
||||
// Files up to 20 MB go through /drive/v1/medias/upload_all; larger files are
|
||||
// streamed via upload_prepare / upload_part / upload_finish. This matches the
|
||||
// pattern used by docs +media-upload and drive +import.
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+media-upload",
|
||||
Description: "Upload a local image for use as a floating image and return the file_token",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.Desc("chunked media upload (files > 20MB)").
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry.Set("spreadsheet_token", parentNode)
|
||||
}
|
||||
return dry.Desc("multipart/form-data upload").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("spreadsheet_token", parentNode)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
|
||||
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
fileToken, err := uploadSheetMediaFile(runtime, filePath, fileName, stat.Size(), parentNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"size": stat.Size(),
|
||||
"spreadsheet_token": parentNode,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// resolveSheetMediaUploadParent returns the spreadsheet token to use as parent_node,
|
||||
// accepting either --url or --spreadsheet-token.
|
||||
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != "" {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// uploadSheetMediaFile routes to the single-part or multipart upload path based
|
||||
// on file size. Always uses parent_type=sheet_image so the returned token can
|
||||
// be consumed by +create-float-image.
|
||||
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
pn := parentNode
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
// sheetMediaShouldUseMultipart mirrors docMediaShouldUseMultipart: dry-run uses
|
||||
// local stat as a best-effort planning hint. Execute re-validates before
|
||||
// choosing the actual upload path.
|
||||
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSheetMediaUploadValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload", "--file", "img.png", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "upload_prepare") {
|
||||
t.Fatalf("dry-run should not use multipart for small file, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shtFromURL") {
|
||||
t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
// Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk.
|
||||
largeFile, err := os.Create("big.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := largeFile.Truncate(20*1024*1024 + 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = largeFile.Close()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err = mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "big.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v1/medias/upload_prepare",
|
||||
"/open-apis/drive/v1/medias/upload_part",
|
||||
"/open-apis/drive/v1/medias/upload_finish",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run should include %q for large file, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "upload_all") {
|
||||
t.Fatalf("dry-run should not use upload_all for large file, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "boxTOK123"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "img.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("parse output: %v", err)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data["file_token"] != "boxTOK123" {
|
||||
t.Fatalf("file_token = %v, want boxTOK123", data["file_token"])
|
||||
}
|
||||
if data["spreadsheet_token"] != "shtSTUB" {
|
||||
t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"])
|
||||
}
|
||||
|
||||
body := decodeSheetsMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_type"]; got != sheetImageParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "shtSTUB" {
|
||||
t.Fatalf("parent_node = %q, want shtSTUB", got)
|
||||
}
|
||||
if got := body.Fields["file_name"]; got != "img.png" {
|
||||
t.Fatalf("file_name = %q, want img.png", got)
|
||||
}
|
||||
if got := body.Fields["size"]; got != "9" {
|
||||
t.Fatalf("size = %q, want 9 (len of png-bytes)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadFileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "missing.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
|
||||
t.Fatalf("err = %v, want file-not-found error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with
|
||||
// t.Parallel — chdir is process-wide.
|
||||
func withSheetsTestWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
}
|
||||
|
||||
type capturedSheetsMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
_, _ = buf.ReadFrom(part)
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = buf.Bytes()
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = buf.String()
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -51,7 +51,7 @@ var SheetSetStyle = common.Shortcut{
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
return common.NewDryRunAPI().
|
||||
@@ -70,7 +70,7 @@ var SheetSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
|
||||
@@ -40,5 +40,11 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
SheetGetFloatImage,
|
||||
SheetListFloatImages,
|
||||
SheetDeleteFloatImage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ Drive Folder (云空间文件夹)
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--selection-with-ellipsis` 或 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
|
||||
| 添加全文评论 | `file_token` | 不传 `--selection-with-ellipsis` / `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
| 列出文档评论 | `file_token` | 同添加评论 |
|
||||
@@ -111,8 +111,8 @@ Drive Folder (云空间文件夹)
|
||||
### 评论能力边界(关键!)
|
||||
|
||||
- `drive +add-comment` 支持两种模式。
|
||||
- 全文评论:未传 `--selection-with-ellipsis` / `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL。
|
||||
- 局部评论:传 `--selection-with-ellipsis` 或 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。
|
||||
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL。
|
||||
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --detail with-ids` 获取。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
|
||||
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
|
||||
### CRITICAL — 首次使用任何命令前先查 `-h`
|
||||
@@ -104,15 +104,17 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
|
||||
### 命令选择:先判断邮件类型,再决定草稿还是发送
|
||||
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 |
|
||||
|----------|-----------------|---------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` |
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 | 定时发送 |
|
||||
|----------|-----------------|---------|----------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` | `+send --confirm-send --send-time <unix_timestamp>` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` | `+reply --confirm-send --send-time <unix_timestamp>` 或 `+reply-all --confirm-send --send-time <unix_timestamp>` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
|
||||
|
||||
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
|
||||
- **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`**
|
||||
- **发送后必须调用 `send_status` 确认投递状态**(详见下方说明)
|
||||
- **立即发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
|
||||
|
||||
> **定时发送注意事项**:`--send-time` 必须与 `--confirm-send` 配合使用,不能单独使用。`send_time` 为 Unix 时间戳(秒),需至少为当前时间 + 5 分钟。
|
||||
|
||||
### 使用公共邮箱或别名(send_as)发信
|
||||
|
||||
@@ -151,7 +153,7 @@ lark-cli mail +send --mailbox me --from alias@example.com \
|
||||
|
||||
### 发送后确认投递状态
|
||||
|
||||
邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
**立即发送(无 `--send-time`)**:邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -159,6 +161,14 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
|
||||
|
||||
**定时发送(指定了 `--send-time`)**:定时发送不会立即产生 `message_id`,`send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
### 撤回邮件
|
||||
|
||||
发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。
|
||||
|
||||
29
skill-template/domains/wiki.md
Normal file
29
skill-template/domains/wiki.md
Normal file
@@ -0,0 +1,29 @@
|
||||
> **成员管理硬限制:**
|
||||
> - 如果目标是“部门”,先判断身份,再决定是否继续。
|
||||
> - `--as bot` 对应 `tenant_access_token`。官方限制:这种身份下不能使用部门 ID (`opendepartmentid`) 添加知识空间成员。
|
||||
> - 遇到“部门 + --as bot”时,禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。
|
||||
> - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID,再执行 `wiki members create`。
|
||||
|
||||
## 成员添加流程
|
||||
|
||||
- 调用 `lark-cli wiki members create` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `member_id`,不要猜格式。
|
||||
- 用户场景默认优先 `member_type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`。
|
||||
- 群组场景使用 `member_type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`。
|
||||
- `userid` / `unionid` 只在下游明确要求时才使用;先拿到 `open_id`,再调用 `lark-cli api GET /open-apis/contact/v3/users/<open_id> --params '{"user_id_type":"open_id"}' --format json` 读取 `user_id` / `union_id`。
|
||||
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut,需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`。
|
||||
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`。
|
||||
|
||||
## 目标语义约束
|
||||
|
||||
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
|
||||
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作
|
||||
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
|
||||
@@ -26,9 +26,11 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
- `get` — 获取单个审批实例详情
|
||||
- `cancel` — 撤回审批实例
|
||||
- `cc` — 抄送审批实例
|
||||
- `initiated` — 查询用户的已发起列表
|
||||
|
||||
### tasks
|
||||
|
||||
- `remind` — 催办审批人
|
||||
- `approve` — 同意审批任务
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
@@ -41,6 +43,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
| `instances.get` | `approval:instance:read` |
|
||||
| `instances.cancel` | `approval:instance:write` |
|
||||
| `instances.cc` | `approval:instance:write` |
|
||||
| `instances.initiated` | `approval:instance:read` |
|
||||
| `tasks.remind` | `approval:instance:write` |
|
||||
| `tasks.approve` | `approval:task:write` |
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
|
||||
@@ -250,11 +250,31 @@ metadata:
|
||||
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
|
||||
### 3.5 执行身份与人员字段
|
||||
### 3.5 身份选择与权限降级策略
|
||||
|
||||
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份操作其有权访问的 Base。执行前先完成用户授权:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain base
|
||||
```
|
||||
|
||||
- **`--as bot`(降级)**:仅当 user 身份权限不足、且 bot 身份确实拥有目标 Base 的访问权限时,才降级使用。bot 看不到用户私有资源,行为以应用身份执行。
|
||||
|
||||
**执行规则**:
|
||||
|
||||
1. 所有操作默认先用 `--as user`。
|
||||
2. 若 user 身份返回权限错误,先判断是否为**不可重试错误码**(如 `91403`)。若是,**立即停止**,不做任何重试或降级,直接按 `lark-shared` 权限不足处理流程引导用户解决。
|
||||
3. 非不可重试错误码时,检查错误响应中是否包含 `permission_violations` / `hint` 等提权引导信息:
|
||||
- **有提权引导**:按 `lark-shared` 权限不足处理流程,先引导用户完成 user 身份提权(`auth login --scope`);确认提权成功后,以 `--as user` 重试。
|
||||
- **无提权引导**(如资源级无访问权限、非 scope 不足):切换到 `--as bot` 重试**一次**。
|
||||
4. 若 bot 身份仍然返回权限错误,**立即停止重试**,根据错误响应按 `lark-shared` 流程引导用户解决(引导去开发者后台开通 scope 或确认资源访问权限)。
|
||||
5. 只有在用户明确要求"用应用身份 / bot 身份操作",才跳过 user 直接使用 `--as bot`。
|
||||
|
||||
**补充说明**:
|
||||
|
||||
- 人员字段 / 用户字段:注意 `user_id_type` 与执行身份(user / bot)差异。
|
||||
- bot 身份:bot 看不到用户私有资源;行为以应用身份执行。
|
||||
- user 身份:依赖用户授权和 scope;更适合操作用户资源。
|
||||
|
||||
## 4. 执行规则
|
||||
|
||||
@@ -296,7 +316,7 @@ metadata:
|
||||
- 删除记录 / 字段 / 表时,如果用户已经明确说要删除,且目标明确,`+record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。
|
||||
- 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。
|
||||
- `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。
|
||||
- 若 Base 由 bot 身份创建且当前 CLI 存在可用 user 身份,优先继续补授当前 user 为 `full_access`;owner 转移必须单独确认,禁止擅自执行。
|
||||
- 若 Base 由 bot 身份创建或复制,shortcut 会自动尝试为当前 CLI 用户补授 `full_access`,并在输出中返回 `permission_grant`;agent 不需要再手动编排单独授权。owner 转移必须单独确认,禁止擅自执行。
|
||||
|
||||
## 5. 常见错误与恢复
|
||||
|
||||
@@ -313,3 +333,4 @@ metadata:
|
||||
| 系统字段 / 公式字段写入失败 | 只读字段被当成可写字段 | 改为写存储字段,计算结果交给 formula / lookup / 系统字段自动产出 |
|
||||
| `1254104` | 批量超 200 条 | 分批调用 |
|
||||
| `1254291` | 并发写冲突 | 串行写入 + 批次间延迟 |
|
||||
| `91403` | 无权限访问该 Base | **不要重试**。按 `lark-shared` 权限不足处理流程引导用户解决权限问题 |
|
||||
|
||||
@@ -47,19 +47,14 @@ POST /open-apis/base/v3/bases/:base_token/copy
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果 Base 是**以应用身份(bot)复制**出来的,agent 在复制成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。
|
||||
> 推荐流程:
|
||||
> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id`
|
||||
> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限
|
||||
> 如果 Base 是**以应用身份(bot)复制**出来的,shortcut 会在复制成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。
|
||||
>
|
||||
> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。
|
||||
> `permission_grant.status` 语义如下:
|
||||
> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限
|
||||
> - `skipped`:Base 已复制成功,但没有可授权的当前 CLI 用户,或复制结果缺少可授权 token
|
||||
> - `failed`:Base 已复制成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果:
|
||||
> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限
|
||||
> - 如果本地没有可用的 user 身份:明确说明因此未完成授权
|
||||
> - 如果授权失败:明确说明 Base 已复制成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。
|
||||
> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。
|
||||
>
|
||||
> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
|
||||
@@ -42,19 +42,14 @@ POST /open-apis/base/v3/bases
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果 Base 是**以应用身份(bot)创建**的,agent 在创建成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。
|
||||
> 推荐流程:
|
||||
> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id`
|
||||
> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限
|
||||
> 如果 Base 是**以应用身份(bot)创建**的,shortcut 会在创建成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。
|
||||
>
|
||||
> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。
|
||||
> `permission_grant.status` 语义如下:
|
||||
> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限
|
||||
> - `skipped`:Base 已创建成功,但没有可授权的当前 CLI 用户,或创建结果缺少可授权 token
|
||||
> - `failed`:Base 已创建成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果:
|
||||
> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限
|
||||
> - 如果本地没有可用的 user 身份:明确说明因此未完成授权
|
||||
> - 如果授权失败:明确说明 Base 已创建成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。
|
||||
> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。
|
||||
>
|
||||
> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
|
||||
@@ -1,178 +1,55 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 1.0.0
|
||||
description: "飞书云文档:创建和编辑飞书文档。从 Markdown 创建文档、获取文档内容、更新文档(追加/覆盖/替换/插入/删除)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
|
||||
version: 2.0.0
|
||||
description: "飞书云文档:创建和编辑飞书文档。默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式,可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文)、更新文档(八种指令:str_replace/str_delete/block_insert_after/block_replace/block_delete/block_move_after/overwrite/append)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help"
|
||||
---
|
||||
|
||||
# docs (v1)
|
||||
# docs (v2)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 文档类型与 Token
|
||||
|
||||
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`。
|
||||
|
||||
### 文档 URL 格式与 Token 处理
|
||||
|
||||
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|
||||
|----------|---------------------------------------------------------|-----------|----------|
|
||||
| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
|
||||
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
|
||||
|
||||
#### 处理流程
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型(docx/doc/sheet/bitable/slides/file/mindnote)
|
||||
- `node.obj_token`:**真实的文档 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
|
||||
3. **根据 `obj_type` 使用对应的 API**
|
||||
|
||||
| obj_type | 说明 | 使用的 API |
|
||||
|----------|------|-----------|
|
||||
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
|
||||
| `doc` | 旧版云文档 | `drive file.comments.*` |
|
||||
| `sheet` | 电子表格 | `sheets.*` |
|
||||
| `bitable` | 多维表格 | `bitable.*` |
|
||||
| `slides` | 幻灯片 | `drive.*` |
|
||||
| `file` | 文件 | `drive.*` |
|
||||
| `mindnote` | 思维导图 | `drive.*` |
|
||||
|
||||
#### 查询示例
|
||||
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create`、`docs +fetch`、`docs +update` 命令必须携带 `--api-version v2`。**
|
||||
|
||||
```bash
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
# 常用示例
|
||||
lark-cli docs +fetch --api-version v2 --doc "文档URL或token"
|
||||
lark-cli docs +create --api-version v2 --content '<title>标题</title><p>内容</p>'
|
||||
lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '<p>内容</p>'
|
||||
```
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "docx",
|
||||
"obj_token": "xxxx",
|
||||
"title": "标题",
|
||||
"node_type": "origin",
|
||||
"space_id": "12345678910"
|
||||
}
|
||||
}
|
||||
```
|
||||
## 前置条件 — 执行操作前必读
|
||||
|
||||
### 资源关系
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
```
|
||||
Wiki Space (知识空间)
|
||||
└── Wiki Node (知识库节点)
|
||||
├── obj_type: docx (新版文档)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: doc (旧版文档)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: sheet (电子表格)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: bitable (多维表格)
|
||||
│ └── obj_token (真实文档 token)
|
||||
└── obj_type: file/slides/mindnote
|
||||
└── obj_token (真实文档 token)
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误、格式错误或样式不达标。**
|
||||
|
||||
Drive Folder (云空间文件夹)
|
||||
└── File (文件/文档)
|
||||
└── file_token (直接使用)
|
||||
```
|
||||
|
||||
## 重要说明:画板编辑
|
||||
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
|
||||
### 场景 1:已通过 docs +fetch 获取到文档内容和画板 token
|
||||
如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
|
||||
1. 记录画板的 token
|
||||
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 场景 2:刚创建画板,需要编辑
|
||||
如果用户刚通过 `docs +update` 创建了空白画板,需要编辑时:
|
||||
**步骤 1:按空白画板语法创建**
|
||||
- 在 `--markdown` 中直接传 `<whiteboard type="blank"></whiteboard>`
|
||||
- 需要多个空白画板时,在同一个 `--markdown` 里重复多个 whiteboard 标签
|
||||
**步骤 2:从响应中记录 token**
|
||||
- `docs +update` 成功后,读取响应字段 `data.board_tokens`
|
||||
- `data.board_tokens` 是新建画板的 token 列表,后续编辑直接使用这里的 token
|
||||
**步骤 3:引导编辑**
|
||||
- 记录需要编辑的画板 token
|
||||
- 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 注意事项
|
||||
- 已有画板内容无法通过 lark-doc 的 `docs +update` 直接编辑
|
||||
- 编辑画板需要使用专门的 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md)
|
||||
|
||||
## 文档可视化建议
|
||||
> **💡 在撰写文档时,当需要表达较为复杂的时序、架构层次、逻辑关系、数据流程等内容时,建议使用画板绘制可视化图表以显著提升文档的可阅读性。**
|
||||
>
|
||||
> 请参考 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何绘制画板内容。
|
||||
> **格式选择规则(全局):** `docs +create` 和 `docs +update` 始终使用 XML 格式(`--doc-format xml`,即默认值),除非用户明确要求使用 Markdown。XML 支持 callout、grid、checkbox 等丰富 block 类型——不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
|
||||
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
|
||||
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
|
||||
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
|
||||
- `docs +search` 不是只搜文档 / Wiki;结果里会直接返回 `SHEET` 等云空间对象。
|
||||
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
|
||||
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”,**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户说"找一个表格""按名称搜电子表格""找报表""最近打开的表格" → 先用 `lark-cli docs +search` 做资源发现
|
||||
- `docs +search` 不只搜文档/Wiki,结果里会直接返回 `SHEET` 等云空间对象
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
- 文档内容中出现嵌入的 `<sheet>`、`<bitable>` 或 `<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
|
||||
|
||||
## 画板需求挖掘(主动识别)
|
||||
| 标签 / 属性 | 提取字段 | 切到技能 |
|
||||
|-|-|-|
|
||||
| `<sheet token="..." sheet-id="...">` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
|
||||
|
||||
> **用户很少主动提"画板"。创建文档时应主动识别适合可视化的内容,用画板呈现。**
|
||||
|
||||
### 🔴 关键要求(必须遵守)
|
||||
|
||||
**创建空白画板 ≠ 完成任务**。创建空白画板后,**必须继续使用 lark-whiteboard 技能填充实际内容**。
|
||||
|
||||
### 语义与画板类型映射
|
||||
|
||||
创建/编辑文档时,文档主题涉及以下语义,应**主动**创建画板,无需用户指定:
|
||||
|
||||
| 语义 | 画板类型 | 参考指南 |
|
||||
|---------------|-------|---------------------------------------------------------------------------------------------|
|
||||
| 架构/分层/技术方案 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 流程/审批/部署/业务流转 | 流程图 | [lark-whiteboard-cli/scenes/flowchart.md](../lark-whiteboard-cli/scenes/flowchart.md) |
|
||||
| 组织/层级/汇报关系 | 组织架构图 | [lark-whiteboard-cli/scenes/organization.md](../lark-whiteboard-cli/scenes/organization.md) |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 | [lark-whiteboard-cli/scenes/milestone.md](../lark-whiteboard-cli/scenes/milestone.md) |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 | [lark-whiteboard-cli/scenes/fishbone.md](../lark-whiteboard-cli/scenes/fishbone.md) |
|
||||
| 方案对比/技术选型 | 对比图 | [lark-whiteboard-cli/scenes/comparison.md](../lark-whiteboard-cli/scenes/comparison.md) |
|
||||
| 循环/飞轮/闭环 | 飞轮图 | [lark-whiteboard-cli/scenes/flywheel.md](../lark-whiteboard-cli/scenes/flywheel.md) |
|
||||
| 层级占比/能力模型 | 金字塔图 | [lark-whiteboard-cli/scenes/pyramid.md](../lark-whiteboard-cli/scenes/pyramid.md) |
|
||||
| 模块依赖/调用关系 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 分类梳理/知识体系 | 思维导图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
| 数据分布/占比 | 饼图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
|
||||
创建画板前,务必先阅读 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 和 [`lark-whiteboard`](../lark-whiteboard/SKILL.md) 这两个 Skill,了解画板的创建流程。
|
||||
|
||||
### 完整执行流程(必须完整执行)
|
||||
|
||||
1. **创建空白画板占位**:创建场景用 `docs +create`、编辑场景用 `docs +update` 插入空白画板
|
||||
2. **获取画板 token**:从 `docs +update` 响应的 `data.board_tokens` 获取画板 token 列表
|
||||
3. **填充画板内容**:切换到 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 创建画板内容,并填入画板
|
||||
4. **验证完成**:确认所有画板都有实际内容,不是空白
|
||||
|
||||
**不适用**:纯文字记录(日志/备忘)、数据密集型内容(用表格)、用户明确只要文字。
|
||||
|
||||
> ⚠️ **警告**:如果只创建空白画板而不填充内容,任务将被视为未完成!
|
||||
|
||||
## 补充说明
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
**补充:** `docs +search` 也承担"先定位云空间对象,再切回对应业务 skill 操作"的资源发现入口角色;当用户口头说"表格/报表"时,也优先从这里开始。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -181,9 +58,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-doc-search.md) | Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search) |
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+whiteboard-update`](references/lark-doc-whiteboard-update.md) | Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details. |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user