mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34ee5bef9 | ||
|
|
052e2112bf | ||
|
|
76a834e928 | ||
|
|
20761fa56a | ||
|
|
2a301246f9 | ||
|
|
abc374f1a3 | ||
|
|
2910cde73a | ||
|
|
7fdc162ff7 | ||
|
|
06e7ae267c | ||
|
|
74f7de386a | ||
|
|
c2b132945e | ||
|
|
88fd3bdab8 | ||
|
|
c70c3fdce2 | ||
|
|
c13f240b9b | ||
|
|
88bf7fc1cd | ||
|
|
25534d72b5 | ||
|
|
815db0c866 | ||
|
|
bb7957245b | ||
|
|
3917b77e91 | ||
|
|
dc0d92708b | ||
|
|
085ffd87f3 | ||
|
|
f6b8091843 | ||
|
|
0e7f507efb |
80
.github/workflows/cli-e2e.yml
vendored
80
.github/workflows/cli-e2e.yml
vendored
@@ -25,6 +25,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
cli-e2e:
|
||||
@@ -65,71 +67,17 @@ jobs:
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
exit 1
|
||||
fi
|
||||
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
|
||||
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
|
||||
- name: Summarize CLI E2E test report
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
report_path = "cli-e2e-report.xml"
|
||||
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
|
||||
|
||||
root = ET.parse(report_path).getroot()
|
||||
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||
|
||||
tests = failures = errors = skipped = 0
|
||||
failed_cases = []
|
||||
skipped_cases = []
|
||||
|
||||
for suite in suites:
|
||||
tests += int(suite.attrib.get("tests", 0))
|
||||
failures += int(suite.attrib.get("failures", 0))
|
||||
errors += int(suite.attrib.get("errors", 0))
|
||||
skipped += int(suite.attrib.get("skipped", 0))
|
||||
|
||||
for case in suite.findall("testcase"):
|
||||
classname = case.attrib.get("classname", "")
|
||||
name = case.attrib.get("name", "")
|
||||
label = f"{classname}.{name}" if classname else name
|
||||
|
||||
failure = case.find("failure")
|
||||
error = case.find("error")
|
||||
skipped_node = case.find("skipped")
|
||||
|
||||
if failure is not None or error is not None:
|
||||
message = ""
|
||||
node = failure if failure is not None else error
|
||||
if node is not None:
|
||||
message = node.attrib.get("message", "") or (node.text or "").strip()
|
||||
failed_cases.append((label, message))
|
||||
elif skipped_node is not None:
|
||||
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
|
||||
skipped_cases.append((label, message))
|
||||
|
||||
passed = tests - failures - errors - skipped
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as f:
|
||||
f.write("## CLI E2E Test Report\n\n")
|
||||
f.write(f"- Total: {tests}\n")
|
||||
f.write(f"- Passed: {passed}\n")
|
||||
f.write(f"- Failed: {failures}\n")
|
||||
f.write(f"- Errors: {errors}\n")
|
||||
f.write(f"- Skipped: {skipped}\n\n")
|
||||
|
||||
if failed_cases:
|
||||
f.write("### Failed Tests\n\n")
|
||||
for label, message in failed_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
|
||||
if skipped_cases:
|
||||
f.write("### Skipped Tests\n\n")
|
||||
for label, message in skipped_cases:
|
||||
detail = f" - {message}" if message else ""
|
||||
f.write(f"- `{label}`{detail}\n")
|
||||
f.write("\n")
|
||||
PY
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
path: cli-e2e-report.xml
|
||||
reporter: java-junit
|
||||
use-actions-summary: true
|
||||
list-suites: all
|
||||
list-tests: all
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -2,6 +2,48 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.11] - 2026-04-14
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
|
||||
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
|
||||
- Streamline interactive login by removing the extra auth confirmation step (#451)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **sheets**: Document value formats for formulas and special field types (#456)
|
||||
- **readme**: Add Attendance to the features table (#460)
|
||||
|
||||
## [v1.0.10] - 2026-04-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support im oapi range download for large files (#283)
|
||||
- **sheets**: Add filter view and condition shortcuts (#422)
|
||||
- **wiki**: Add wiki move shortcut with async task polling (#436)
|
||||
- **drive**: Add drive `+create-shortcut` shortcut (#432)
|
||||
- **drive**: Add drive files patch metadata API (#444)
|
||||
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Support large base attachment uploads (#441)
|
||||
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
|
||||
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
|
||||
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
|
||||
- **mail**: Restrict `--output-dir` to current working directory (#376)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
|
||||
- **task**: Document sections API resources, permissions, and URL parsing (#430)
|
||||
- **doc**: Clarify when markdown escaping is needed (#312)
|
||||
|
||||
## [v1.0.9] - 2026-04-11
|
||||
|
||||
### Features
|
||||
@@ -303,6 +345,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 21 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -36,6 +36,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 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 |
|
||||
|
||||
## Installation & Quick Start
|
||||
@@ -149,6 +150,7 @@ lark-cli auth status
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -36,6 +36,7 @@
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
|
||||
## 安装与快速开始
|
||||
@@ -150,6 +151,7 @@ lark-cli auth status
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
|
||||
@@ -184,27 +184,6 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
|
||||
|
||||
// Phase 2: confirmation
|
||||
var confirmed bool
|
||||
form2 := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(msg.ConfirmAuth).
|
||||
Value(&confirmed),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form2.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
|
||||
return &interactiveResult{
|
||||
Domains: selectedDomains,
|
||||
ScopeLevel: permLevel,
|
||||
|
||||
@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
||||
|
||||
// Show QR code in terminal
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
// Branch on TTY: human-friendly copy in interactive terminals,
|
||||
// preserve original copy for AI / non-interactive callers.
|
||||
if f.IOStreams.IsTerminal {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
} else {
|
||||
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
||||
if qrErr == nil {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
|
||||
// Step 3: Poll for result
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("%v", err)
|
||||
|
||||
@@ -16,11 +16,16 @@ type initMsg struct {
|
||||
Platform string
|
||||
SelectPlatform string
|
||||
Feishu string
|
||||
ScanOrOpenLink string
|
||||
WaitingForScan string
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
// TTY (interactive) variants
|
||||
ScanQRCode string // header shown above QR code
|
||||
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
|
||||
WaitingForScan string // active polling indicator
|
||||
// Non-TTY (AI / non-interactive) variants — preserve original copy
|
||||
OpenLinkNonTTY string // primary link prompt
|
||||
WaitingForScanNonTTY string // passive waiting indicator
|
||||
DetectedLarkTenant string
|
||||
AppCreated string
|
||||
ConfigSaved string
|
||||
}
|
||||
|
||||
var initMsgZh = &initMsg{
|
||||
@@ -29,12 +34,15 @@ var initMsgZh = &initMsg{
|
||||
ConfigExistingApp: "手动输入应用凭证",
|
||||
Platform: "平台",
|
||||
SelectPlatform: "选择平台",
|
||||
Feishu: "飞书",
|
||||
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScan: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
Feishu: "飞书",
|
||||
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
|
||||
ScanOrOpenLink: "\n或打开以下链接完成配置:\n",
|
||||
WaitingForScan: "正在获取你的应用配置结果...",
|
||||
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
|
||||
WaitingForScanNonTTY: "等待配置应用...",
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
@@ -43,12 +51,15 @@ var initMsgEn = &initMsg{
|
||||
ConfigExistingApp: "Enter app credentials yourself",
|
||||
Platform: "Platform",
|
||||
SelectPlatform: "Select platform",
|
||||
Feishu: "Feishu",
|
||||
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScan: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
Feishu: "Feishu",
|
||||
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
|
||||
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
|
||||
WaitingForScan: "Fetching configuration results...",
|
||||
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
|
||||
WaitingForScanNonTTY: "Waiting for app configuration...",
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
}
|
||||
|
||||
func getInitMsg(lang string) *initMsg {
|
||||
|
||||
@@ -54,11 +54,14 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
"Platform": msg.Platform,
|
||||
"SelectPlatform": msg.SelectPlatform,
|
||||
"Feishu": msg.Feishu,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
"ScanQRCode": msg.ScanQRCode,
|
||||
"ScanOrOpenLink": msg.ScanOrOpenLink,
|
||||
"WaitingForScan": msg.WaitingForScan,
|
||||
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
|
||||
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
}
|
||||
for name, val := range fields {
|
||||
if val == "" {
|
||||
|
||||
@@ -33,6 +33,11 @@ const (
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
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"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
64
internal/output/lark_errors_test.go
Normal file
64
internal/output/lark_errors_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
|
||||
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
wantExitCode int
|
||||
wantType string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: LarkErrDriveResourceContention,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
},
|
||||
{
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: LarkErrDriveCrossBrand,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
|
||||
if gotExitCode != tt.wantExitCode {
|
||||
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
|
||||
}
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
|
||||
}
|
||||
if gotHint == "" {
|
||||
t.Fatal("expected non-empty hint")
|
||||
}
|
||||
if !strings.Contains(gotHint, tt.wantHint) {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -151,6 +151,87 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "field create",
|
||||
shortcut: BaseFieldCreate,
|
||||
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "field update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record upsert",
|
||||
shortcut: BaseRecordUpsert,
|
||||
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set filter",
|
||||
shortcut: BaseViewSetFilter,
|
||||
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set visible fields",
|
||||
shortcut: BaseViewSetVisibleFields,
|
||||
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set card",
|
||||
shortcut: BaseViewSetCard,
|
||||
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "view set timebar",
|
||||
shortcut: BaseViewSetTimebar,
|
||||
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err should not mention array: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Fatalf("stdout=%q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -259,7 +340,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
|
||||
@@ -277,7 +358,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
|
||||
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
|
||||
@@ -865,6 +946,157 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment uses multipart for large file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_big_1",
|
||||
"block_size": float64(8 * 1024 * 1024),
|
||||
"block_num": float64(3),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
t.Fatalf("prepare body=%s", prepareBody)
|
||||
}
|
||||
|
||||
firstPartBody := string(partStubs[0].CapturedBody)
|
||||
if !strings.Contains(firstPartBody, `name="upload_id"`) ||
|
||||
!strings.Contains(firstPartBody, "upload_big_1") ||
|
||||
!strings.Contains(firstPartBody, `name="seq"`) ||
|
||||
!strings.Contains(firstPartBody, "\r\n0\r\n") ||
|
||||
!strings.Contains(firstPartBody, `name="size"`) ||
|
||||
!strings.Contains(firstPartBody, "8388608") {
|
||||
t.Fatalf("first part body=%s", firstPartBody)
|
||||
}
|
||||
|
||||
lastPartBody := string(partStubs[2].CapturedBody)
|
||||
if !strings.Contains(lastPartBody, `name="seq"`) ||
|
||||
!strings.Contains(lastPartBody, "\r\n2\r\n") ||
|
||||
!strings.Contains(lastPartBody, `name="size"`) ||
|
||||
!strings.Contains(lastPartBody, "4194305") {
|
||||
t.Fatalf("last part body=%s", lastPartBody)
|
||||
}
|
||||
|
||||
finishBody := string(finishStub.CapturedBody)
|
||||
if !strings.Contains(finishBody, `"upload_id":"upload_big_1"`) ||
|
||||
!strings.Contains(finishBody, `"block_num":3`) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects non-attachment field", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -904,6 +1136,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects file larger than 2GB", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-too-large-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Truncate(2*1024*1024*1024 + 1); err != nil {
|
||||
t.Fatalf("Truncate() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
@@ -1021,7 +1284,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
factory,
|
||||
stdout,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
func jsonInputTip(flagName string) string {
|
||||
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate != nil {
|
||||
t.Fatalf("expected no validate hook, got non-nil")
|
||||
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate == nil {
|
||||
t.Fatal("expected validate hook")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -255,22 +255,29 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate != nil {
|
||||
t.Fatalf("record search validate should be nil for API passthrough")
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
}
|
||||
if BaseRecordUpsert.Validate != nil {
|
||||
t.Fatalf("record upsert validate should be nil for API passthrough")
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("create validate err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
|
||||
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +81,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
|
||||
|
||||
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = common.ParseJSON([]byte(raw), &body)
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return body, nil
|
||||
return parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
}
|
||||
|
||||
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -36,7 +37,14 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
t.Fatalf("obj=%v err=%v", obj, err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
|
||||
@@ -63,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -281,11 +284,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchCreate(runtime)
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordBatchUpdate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchUpdate(runtime)
|
||||
|
||||
@@ -113,7 +113,9 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseRecordSearch = common.Shortcut{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
|
||||
@@ -5,15 +5,11 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -21,8 +17,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
)
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
@@ -37,7 +33,7 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
},
|
||||
DryRun: dryRunRecordUploadAttachment,
|
||||
@@ -52,7 +48,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Desc("[1] Read target field and ensure it is an attachment field").
|
||||
@@ -61,15 +57,42 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
Set("field_id", runtime.Str("field-id")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id")).
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
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").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
} else {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"file": "@" + filePath,
|
||||
"size": "<file_size>",
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
Body(map[string]interface{}{
|
||||
@@ -102,7 +125,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024)
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
@@ -124,6 +147,9 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
@@ -151,6 +177,14 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
|
||||
func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
@@ -209,47 +243,30 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
err error
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: &parentNode,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("cannot open file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", baseAttachmentParentType)
|
||||
fd.AddField("parent_node", baseToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
|
||||
code, _ := util.ToFloat64(result["code"])
|
||||
if code != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
|
||||
@@ -26,6 +26,9 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordUpsert,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUpsert(runtime)
|
||||
|
||||
@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
|
||||
}
|
||||
|
||||
func validateViewCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseObjectList(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func validateViewJSONObject(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateViewJSONValue(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
pc := newParseCtx(runtime)
|
||||
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return err
|
||||
}
|
||||
|
||||
func executeViewList(runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetGroup,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONValue(runtime)
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetSort,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -26,6 +26,9 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
},
|
||||
DryRun: dryRunViewSetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewSetVisibleFields(runtime)
|
||||
|
||||
136
shortcuts/drive/drive_create_shortcut.go
Normal file
136
shortcuts/drive/drive_create_shortcut.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveCreateShortcutAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
type driveCreateShortcutSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
|
||||
return driveCreateShortcutSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
}
|
||||
}
|
||||
|
||||
// RequestBody builds the create_shortcut API payload from the shortcut spec.
|
||||
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"parent_token": s.FolderToken,
|
||||
"refer_entity": map[string]interface{}{
|
||||
"refer_token": s.FileToken,
|
||||
"refer_type": s.FileType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
|
||||
var DriveCreateShortcut = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+create-shortcut",
|
||||
Description: "Create a Drive shortcut in another folder",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:document:shortcut"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "source file token to reference", Required: true},
|
||||
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Create a Drive shortcut").
|
||||
POST("/open-apis/drive/v1/files/create_shortcut").
|
||||
Desc("[1] Create shortcut").
|
||||
Body(spec.RequestBody())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"Creating shortcut for %s %s in folder %s...\n",
|
||||
spec.FileType,
|
||||
common.MaskToken(spec.FileToken),
|
||||
common.MaskToken(spec.FolderToken),
|
||||
)
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_shortcut",
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"created": true,
|
||||
"source_file_token": spec.FileToken,
|
||||
"source_type": spec.FileType,
|
||||
"folder_token": spec.FolderToken,
|
||||
}
|
||||
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
|
||||
out["shortcut_token"] = shortcutToken
|
||||
}
|
||||
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
|
||||
out["title"] = title
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
|
||||
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
|
||||
}
|
||||
if spec.FileType == "folder" {
|
||||
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
|
||||
}
|
||||
if !driveCreateShortcutAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
|
||||
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveCreateShortcutSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "wiki",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "wiki_token_test",
|
||||
FileType: "wiki",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "underlying file token first",
|
||||
},
|
||||
{
|
||||
name: "folder",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "folder_token_test",
|
||||
FileType: "folder",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "not folders",
|
||||
},
|
||||
{
|
||||
name: "shortcut",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "shortcut_token_test",
|
||||
FileType: "shortcut",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
{
|
||||
name: "missing folder token",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "docx",
|
||||
},
|
||||
wantErr: "--folder-token must not be empty",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "unknown",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCreateShortcutSpec(tt.spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
|
||||
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveCreateShortcut.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "POST" {
|
||||
t.Fatalf("first method = %q, want POST", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
|
||||
}
|
||||
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
|
||||
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"succ_shortcut_node": map[string]interface{}{
|
||||
"token": "shortcut_token_test",
|
||||
"name": "shortcut_name_test",
|
||||
"type": "docx",
|
||||
"parent_token": "folder_target_token_test",
|
||||
"url": "https://example.feishu.cn/docx/shortcut_token_test",
|
||||
"shortcut_info": map[string]interface{}{
|
||||
"target_type": "docx",
|
||||
"target_token": "doc_token_test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", " doc_token_test ",
|
||||
"--type", " DOCX ",
|
||||
"--folder-token", " folder_target_token_test ",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
|
||||
}
|
||||
referEntity, _ := body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["shortcut_token"] != "shortcut_token_test" {
|
||||
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
|
||||
}
|
||||
if data["folder_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
|
||||
}
|
||||
if data["source_file_token"] != "doc_token_test" {
|
||||
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
|
||||
}
|
||||
if data["title"] != "shortcut_name_test" {
|
||||
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
|
||||
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
|
||||
}
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
|
||||
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
|
||||
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
|
||||
FileToken: "doc_token_test",
|
||||
FileType: "docx",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
|
||||
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
err := DriveCreateShortcut.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
|
||||
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
msg string
|
||||
wantType string
|
||||
wantHint string
|
||||
wantMsgPart string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: output.LarkErrDriveResourceContention,
|
||||
msg: "resource contention occurred, please retry",
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
wantMsgPart: "resource contention occurred",
|
||||
},
|
||||
{
|
||||
name: "cross tenant and unit",
|
||||
code: output.LarkErrDriveCrossTenantUnit,
|
||||
msg: "cross tenant and unit not support",
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
wantMsgPart: "cross tenant and unit not support",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: output.LarkErrDriveCrossBrand,
|
||||
msg: "cross brand not support",
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
wantMsgPart: "cross brand not support",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(tt.code),
|
||||
"msg": tt.msg,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", "doc_token_test",
|
||||
"--type", "docx",
|
||||
"--folder-token", "folder_token_test",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
}
|
||||
if exitErr.Detail.Code != tt.code {
|
||||
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
|
||||
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,31 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveTaskResult exposes a unified read path for the async task types produced
|
||||
// by Drive import, export, and folder move flows.
|
||||
// by Drive import, export, folder move/delete, and wiki move flows.
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, move, or delete operations",
|
||||
Description: "Poll async task result for import, export, drive move/delete, or wiki move operations",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
// This shortcut multiplexes multiple backend APIs with different scope
|
||||
// requirements, so scenario-specific prechecks are handled in Validate.
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for move/delete folder tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check or wiki_move tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, or wiki_move", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -34,9 +38,10 @@ var DriveTaskResult = common.Shortcut{
|
||||
"import": true,
|
||||
"export": true,
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
@@ -48,9 +53,9 @@ var DriveTaskResult = common.Shortcut{
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check":
|
||||
case "task_check", "wiki_move":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for task_check scenario")
|
||||
return output.ErrValidation("--task-id is required for %s scenario", scenario)
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
@@ -67,7 +72,7 @@ var DriveTaskResult = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return validateDriveTaskResultScopes(ctx, runtime, scenario)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
@@ -92,6 +97,11 @@ var DriveTaskResult = common.Shortcut{
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[1] Query move/delete folder task status").
|
||||
Params(driveTaskCheckParams(taskID))
|
||||
case "wiki_move":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki move task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "move"})
|
||||
}
|
||||
|
||||
return dry
|
||||
@@ -116,6 +126,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
result, err = queryExportTask(runtime, ticket, fileToken)
|
||||
case "task_check":
|
||||
result, err = queryTaskCheck(runtime, taskID)
|
||||
case "wiki_move":
|
||||
result, err = queryWikiMoveTask(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -196,3 +208,263 @@ func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]i
|
||||
"failed": status.Failed(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeContext, scenario string) error {
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err != nil {
|
||||
// Propagate cancellation/timeout so callers stop instead of falling through
|
||||
// to the API call. Other token errors are non-fatal here: the API call will
|
||||
// surface a clearer permission error.
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if result == nil || result.Scopes == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var required []string
|
||||
switch scenario {
|
||||
case "import", "export", "task_check":
|
||||
required = []string{"drive:drive.metadata:readonly"}
|
||||
case "wiki_move":
|
||||
required = []string{"wiki:space:read"}
|
||||
}
|
||||
|
||||
return requireDriveScopes(result.Scopes, required)
|
||||
}
|
||||
|
||||
func requireDriveScopes(storedScopes string, required []string) error {
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := missingDriveScopes(storedScopes, required)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
}
|
||||
|
||||
func missingDriveScopes(storedScopes string, required []string) []string {
|
||||
granted := make(map[string]bool)
|
||||
for _, scope := range strings.Fields(storedScopes) {
|
||||
granted[scope] = true
|
||||
}
|
||||
|
||||
missing := make([]string, 0, len(required))
|
||||
for _, scope := range required {
|
||||
if !granted[scope] {
|
||||
missing = append(missing, scope)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
type wikiMoveTaskResultStatus struct {
|
||||
Node map[string]interface{}
|
||||
Status int
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
type wikiMoveTaskQueryStatus struct {
|
||||
TaskID string
|
||||
MoveResults []wikiMoveTaskResultStatus
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) Ready() bool {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) Failed() bool {
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status < 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) FirstResult() *wikiMoveTaskResultStatus {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.MoveResults[0]
|
||||
}
|
||||
|
||||
// primaryResult picks the most informative move_result for top-level status
|
||||
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
|
||||
// behind an earlier success, then a still-processing entry, and finally fall
|
||||
// back to the first entry.
|
||||
func (s wikiMoveTaskQueryStatus) primaryResult() *wikiMoveTaskResultStatus {
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status < 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status > 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
return s.FirstResult()
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) PrimaryStatusCode() int {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
return r.Status
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskQueryStatus) PrimaryStatusLabel() string {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.Ready():
|
||||
return "success"
|
||||
case s.Failed():
|
||||
return "failure"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
status, err := getWikiMoveTaskStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"scenario": "wiki_move",
|
||||
"task_id": status.TaskID,
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
"status": status.PrimaryStatusCode(),
|
||||
"status_msg": status.PrimaryStatusLabel(),
|
||||
}
|
||||
|
||||
moveResults := make([]map[string]interface{}, 0, len(status.MoveResults))
|
||||
for _, result := range status.MoveResults {
|
||||
item := map[string]interface{}{
|
||||
"status": result.Status,
|
||||
"status_msg": result.StatusMsg,
|
||||
}
|
||||
if result.Node != nil {
|
||||
item["node"] = result.Node
|
||||
}
|
||||
moveResults = append(moveResults, item)
|
||||
}
|
||||
if len(moveResults) > 0 {
|
||||
out["move_results"] = moveResults
|
||||
}
|
||||
|
||||
if first := status.FirstResult(); first != nil {
|
||||
// Mirror the first moved node at the top level so follow-up commands can
|
||||
// reuse a stable field set without digging into move_results[0].node.
|
||||
if first.Node != nil {
|
||||
out["node"] = first.Node
|
||||
appendWikiMoveNodeFields(out, first.Node)
|
||||
if token := common.GetString(first.Node, "node_token"); token != "" {
|
||||
out["wiki_token"] = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiMoveTaskQueryStatus{}, err
|
||||
}
|
||||
|
||||
return parseWikiMoveTaskQueryStatus(taskID, common.GetMap(data, "task"))
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
|
||||
for _, item := range common.GetSlice(task, "move_result") {
|
||||
resultMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
status.MoveResults = append(status.MoveResults, wikiMoveTaskResultStatus{
|
||||
Node: parseWikiMoveTaskNode(common.GetMap(resultMap, "node")),
|
||||
Status: int(common.GetFloat(resultMap, "status")),
|
||||
StatusMsg: common.GetString(resultMap, "status_msg"),
|
||||
})
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskNode(node map[string]interface{}) map[string]interface{} {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(node, "space_id"),
|
||||
"node_token": common.GetString(node, "node_token"),
|
||||
"obj_token": common.GetString(node, "obj_token"),
|
||||
"obj_type": common.GetString(node, "obj_type"),
|
||||
"parent_node_token": common.GetString(node, "parent_node_token"),
|
||||
"node_type": common.GetString(node, "node_type"),
|
||||
"origin_node_token": common.GetString(node, "origin_node_token"),
|
||||
"title": common.GetString(node, "title"),
|
||||
"has_child": common.GetBool(node, "has_child"),
|
||||
}
|
||||
}
|
||||
|
||||
func appendWikiMoveNodeFields(out, node map[string]interface{}) {
|
||||
if out == nil || node == nil {
|
||||
return
|
||||
}
|
||||
out["space_id"] = common.GetString(node, "space_id")
|
||||
out["node_token"] = common.GetString(node, "node_token")
|
||||
out["obj_token"] = common.GetString(node, "obj_token")
|
||||
out["obj_type"] = common.GetString(node, "obj_type")
|
||||
out["parent_node_token"] = common.GetString(node, "parent_node_token")
|
||||
out["node_type"] = common.GetString(node, "node_type")
|
||||
out["origin_node_token"] = common.GetString(node, "origin_node_token")
|
||||
out["title"] = common.GetString(node, "title")
|
||||
out["has_child"] = common.GetBool(node, "has_child")
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -54,6 +57,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
{
|
||||
name: "wiki move missing task id",
|
||||
flags: map[string]string{
|
||||
"scenario": "wiki_move",
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -277,3 +287,259 @@ func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type mockDriveTaskResultTokenResolver struct {
|
||||
token string
|
||||
scopes string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDriveTaskResultTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
token := m.token
|
||||
if token == "" {
|
||||
token = "test-token"
|
||||
}
|
||||
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
|
||||
}
|
||||
|
||||
func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunWikiMoveIncludesTaskTypeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "wiki_move"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("task-id", "task_123"); err != nil {
|
||||
t.Fatalf("set --task-id: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Params["task_type"] != "move" {
|
||||
t.Fatalf("wiki move params = %#v, want task_type=move", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"task_id": "task_123",
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"node_type": "origin",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_move",
|
||||
"--task-id", "task_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["scenario"] != "wiki_move" || data["task_id"] != "task_123" {
|
||||
t.Fatalf("unexpected wiki_move envelope: %#v", data)
|
||||
}
|
||||
if data["ready"] != true || data["failed"] != false || data["wiki_token"] != "wik_done" {
|
||||
t.Fatalf("unexpected readiness fields: %#v", data)
|
||||
}
|
||||
if data["title"] != "Roadmap" || data["obj_type"] != "sheet" || data["space_id"] != "space_dst" {
|
||||
t.Fatalf("flattened node fields missing: %#v", data)
|
||||
}
|
||||
moveResults, ok := data["move_results"].([]interface{})
|
||||
if !ok || len(moveResults) != 1 {
|
||||
t.Fatalf("move_results = %#v, want one result", data["move_results"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesWikiMoveRequiresWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
|
||||
t.Fatalf("expected missing wiki scope error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesWikiMoveAcceptsWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err != nil {
|
||||
t.Fatalf("validateDriveTaskResultScopes() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "import")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): drive:drive.metadata:readonly") {
|
||||
t.Fatalf("expected missing drive scope error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskQueryStatusFallbackTaskIDAndNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status, err := parseWikiMoveTaskQueryStatus("task_fallback", map[string]interface{}{
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiMoveTaskQueryStatus() error = %v", err)
|
||||
}
|
||||
if status.TaskID != "task_fallback" || !status.Ready() || status.PrimaryStatusLabel() != "success" {
|
||||
t.Fatalf("unexpected parsed status: %+v", status)
|
||||
}
|
||||
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node["node_token"] != "wik_done" {
|
||||
t.Fatalf("parsed node = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiMoveTaskQueryStatus("task_123", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing task") {
|
||||
t.Fatalf("expected missing task error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
MoveResults: []wikiMoveTaskResultStatus{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: -3, StatusMsg: "permission denied"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != -3 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "permission denied" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
|
||||
}
|
||||
// FirstResult must keep its literal "first entry" semantics for callers
|
||||
// that flatten node fields from the first move_result.
|
||||
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
|
||||
t.Fatalf("FirstResult = %+v, want first success entry", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskQueryStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
MoveResults: []wikiMoveTaskResultStatus{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != 1 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "processing" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelingTokenResolver struct{}
|
||||
|
||||
func (cancelingTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
|
||||
runtime.Factory = factory
|
||||
|
||||
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
DriveUpload,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
|
||||
@@ -5,12 +5,14 @@ package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+create-shortcut",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
|
||||
@@ -74,6 +74,7 @@ var commonEventTypes = []string{
|
||||
"approval.approval.updated",
|
||||
"application.application.visibility.added_v6",
|
||||
"task.task.update_tenant_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"task.task.comment_updated_v1",
|
||||
"drive.notice.comment_add_v1",
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -395,6 +396,28 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatMessageList rejects both targets", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_abc",
|
||||
"user-id": "ou_123",
|
||||
}, nil)
|
||||
err := ImChatMessageList.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("ImChatMessageList.Validate() error = %v, want mutually exclusive", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatMessageList rejects user target for bot identity", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id": "ou_123",
|
||||
}, nil)
|
||||
setRuntimeField(t, runtime, "resolvedAs", core.AsBot)
|
||||
err := ImChatMessageList.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
|
||||
t.Fatalf("ImChatMessageList.Validate() error = %v, want requires user identity", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesMGet empty ids", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-ids": " , ",
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("user resolved through p2p lookup", func(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
@@ -303,6 +303,23 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
|
||||
t.Fatalf("resolveChatIDForMessagesList() = %q, want %q", got, "oc_resolved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user target rejected for bot identity", func(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("user-id", "", "")
|
||||
if err := cmd.Flags().Set("user-id", "ou_123"); err != nil {
|
||||
t.Fatalf("Flags().Set() error = %v", err)
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
|
||||
_, err := resolveChatIDForMessagesList(runtime, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
|
||||
t.Fatalf("resolveChatIDForMessagesList() error = %v, want requires user identity", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildMessagesSearchRequest(t *testing.T) {
|
||||
|
||||
@@ -377,6 +377,9 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str
|
||||
|
||||
// resolveP2PChatID resolves user open_id to P2P chat_id.
|
||||
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
|
||||
if runtime.IsBot() {
|
||||
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
}
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/chat_p2p/batch_query",
|
||||
|
||||
@@ -6,6 +6,7 @@ package im
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
@@ -107,12 +109,17 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
|
||||
return runtime
|
||||
}
|
||||
|
||||
func newUserShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
runtime := newBotShortcutRuntime(t, rt)
|
||||
setRuntimeField(t, runtime, "resolvedAs", core.AsUser)
|
||||
return runtime
|
||||
}
|
||||
|
||||
func TestResolveP2PChatID(t *testing.T) {
|
||||
var gotAuth string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
|
||||
gotAuth = req.Header.Get("Authorization")
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
@@ -133,13 +140,10 @@ func TestResolveP2PChatID(t *testing.T) {
|
||||
if got != "oc_123" {
|
||||
t.Fatalf("resolveP2PChatID() = %q, want %q", got, "oc_123")
|
||||
}
|
||||
if gotAuth != "Bearer tenant-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer tenant-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveP2PChatIDNotFound(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
@@ -159,6 +163,17 @@ func TestResolveP2PChatIDNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveP2PChatIDRejectsBot(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
|
||||
_, err := resolveP2PChatID(runtime, "ou_123")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
|
||||
t.Fatalf("resolveP2PChatID() error = %v, want requires user identity", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID(t *testing.T) {
|
||||
t.Run("thread id passthrough", func(t *testing.T) {
|
||||
got, err := resolveThreadID(newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
@@ -273,6 +288,46 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
|
||||
if gotHeaders.Get(cmdutil.HeaderExecutionId) != "exec-123" {
|
||||
t.Fatalf("%s = %q, want %q", cmdutil.HeaderExecutionId, gotHeaders.Get(cmdutil.HeaderExecutionId), "exec-123")
|
||||
}
|
||||
if gotHeaders.Get("Range") != fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
|
||||
t.Fatalf("Range header = %q, want %q", gotHeaders.Get("Range"), fmt.Sprintf("bytes=0-%d", probeChunkSize-1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathImageUsesSingleRequestWithoutRange(t *testing.T) {
|
||||
var gotHeaders http.Header
|
||||
payload := []byte("image download")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_img/resources/img_123"):
|
||||
gotHeaders = req.Header.Clone()
|
||||
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"image/png"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
if gotHeaders.Get("Range") != "" {
|
||||
t.Fatalf("Range header = %q, want empty", gotHeaders.Get("Range"))
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "image.png") {
|
||||
t.Fatalf("saved path = %q, want suffix %q", gotPath, "image.png")
|
||||
}
|
||||
data, err := os.ReadFile("image.png")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != string(payload) {
|
||||
t.Fatalf("downloaded payload = %q, want %q", string(data), string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
|
||||
@@ -293,6 +348,348 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRetriesNetworkError(t *testing.T) {
|
||||
attempts := 0
|
||||
payload := []byte("retry success")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry/resources/file_retry"):
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return nil, fmt.Errorf("temporary network failure")
|
||||
}
|
||||
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("download attempts = %d, want 3", attempts)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRetrySecondAttemptSuccess(t *testing.T) {
|
||||
attempts := 0
|
||||
payload := []byte("second retry success")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry2/resources/file_retry2"):
|
||||
attempts++
|
||||
if attempts < 2 {
|
||||
return nil, fmt.Errorf("temporary network failure")
|
||||
}
|
||||
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("download attempts = %d, want 2", attempts)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
|
||||
attempts := 0
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_cancel/resources/file_cancel"):
|
||||
attempts++
|
||||
return nil, fmt.Errorf("temporary network failure")
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel context immediately to trigger context error on first retry
|
||||
cancel()
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target)
|
||||
if err != context.Canceled {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
|
||||
}
|
||||
// First attempt is made, then retry checks ctx.Err() and returns
|
||||
if attempts != 1 {
|
||||
t.Fatalf("download attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeDownload(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payloadLen int64
|
||||
wantRanges []string
|
||||
}{
|
||||
{
|
||||
name: "single small chunk",
|
||||
payloadLen: 16,
|
||||
wantRanges: []string{"bytes=0-131071"},
|
||||
},
|
||||
{
|
||||
name: "exact probe chunk",
|
||||
payloadLen: probeChunkSize,
|
||||
wantRanges: []string{"bytes=0-131071"},
|
||||
},
|
||||
{
|
||||
name: "multiple chunks with tail",
|
||||
payloadLen: probeChunkSize + normalChunkSize + 1234,
|
||||
wantRanges: []string{
|
||||
"bytes=0-131071",
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+normalChunkSize+1233),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple chunks exact 8mb tail",
|
||||
payloadLen: probeChunkSize + 2*normalChunkSize,
|
||||
wantRanges: []string{
|
||||
"bytes=0-131071",
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
|
||||
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+2*normalChunkSize-1),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("range-download-"), int(tt.payloadLen/15)+1)
|
||||
payload = payload[:tt.payloadLen]
|
||||
|
||||
var gotRanges []string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_range/resources/file_range"):
|
||||
rangeHeader := req.Header.Get("Range")
|
||||
gotRanges = append(gotRanges, rangeHeader)
|
||||
if req.Header.Get("Authorization") != "Bearer tenant-token" {
|
||||
return nil, fmt.Errorf("missing authorization header")
|
||||
}
|
||||
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shortcutRawResponse(http.StatusPartialContent, payload[start:end+1], http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := filepath.Join("nested", "resource.bin")
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if size != int64(len(payload)) {
|
||||
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
|
||||
}
|
||||
if !reflect.DeepEqual(gotRanges, tt.wantRanges) {
|
||||
t.Fatalf("Range requests = %#v, want %#v", gotRanges, tt.wantRanges)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if md5.Sum(got) != md5.Sum(payload) {
|
||||
t.Fatalf("downloaded payload MD5 = %x, want %x", md5.Sum(got), md5.Sum(payload))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathInvalidContentRange(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad/resources/file_bad"):
|
||||
return shortcutRawResponse(http.StatusPartialContent, []byte("bad"), http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{"bytes 0-2/not-a-number"},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Content-Range header") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
|
||||
payload = payload[:probeChunkSize+1024]
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_miderr/resources/file_miderr"):
|
||||
rangeHeader := req.Header.Get("Range")
|
||||
if rangeHeader == fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
|
||||
return shortcutRawResponse(http.StatusPartialContent, payload[:probeChunkSize], http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{fmt.Sprintf("bytes 0-%d/%d", probeChunkSize-1, len(payload))},
|
||||
}), nil
|
||||
}
|
||||
return shortcutRawResponse(http.StatusInternalServerError, []byte("chunk failed"), http.Header{"Content-Type": []string{"text/plain"}}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output file exists after failed download, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeOverflowCleansOutput(t *testing.T) {
|
||||
payload := []byte("overflow-payload")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_overflow/resources/file_overflow"):
|
||||
return shortcutRawResponse(http.StatusPartialContent, payload, http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{"bytes 0-3/4"},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target)
|
||||
if err == nil || !strings.Contains(err.Error(), "chunk overflow") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output file exists after overflow, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadIMResourceToPathRangeShortChunkSizeMismatch(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
|
||||
payload = payload[:probeChunkSize+1024]
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_short/resources/file_short"):
|
||||
rangeHeader := req.Header.Get("Range")
|
||||
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := payload[start : end+1]
|
||||
if start == probeChunkSize {
|
||||
body = body[:len(body)-10]
|
||||
}
|
||||
return shortcutRawResponse(http.StatusPartialContent, body, http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin")
|
||||
if err == nil || !strings.Contains(err.Error(), "file size mismatch") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRangeHeader(header string, totalSize int64) (int64, int64, error) {
|
||||
if !strings.HasPrefix(header, "bytes=") {
|
||||
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
|
||||
}
|
||||
|
||||
start, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse start: %w", err)
|
||||
}
|
||||
end, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse end: %w", err)
|
||||
}
|
||||
if start < 0 || end < start || start >= totalSize {
|
||||
return 0, 0, fmt.Errorf("invalid range bounds: %d-%d for size %d", start, end, totalSize)
|
||||
}
|
||||
if end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func TestUploadImageToIMSuccess(t *testing.T) {
|
||||
var gotBody string
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -599,6 +599,44 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTotalSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentRange string
|
||||
want int64
|
||||
wantErr string
|
||||
}{
|
||||
{name: "normal", contentRange: "bytes 0-131071/104857600", want: 104857600},
|
||||
{name: "single probe chunk", contentRange: "bytes 0-131071/131072", want: 131072},
|
||||
{name: "single small chunk", contentRange: "bytes 0-15/16", want: 16},
|
||||
{name: "empty", contentRange: "", wantErr: "content-range is empty"},
|
||||
{name: "invalid prefix", contentRange: "items 0-15/16", wantErr: `unsupported content-range: "items 0-15/16"`},
|
||||
{name: "missing total", contentRange: "bytes 0-15/", wantErr: `unsupported content-range: "bytes 0-15/"`},
|
||||
{name: "wildcard", contentRange: "bytes */16", wantErr: `unsupported content-range: "bytes */16"`},
|
||||
{name: "unknown total size", contentRange: "bytes 0-99/*", wantErr: `unknown total size in content-range: "bytes 0-99/*"`},
|
||||
{name: "invalid total", contentRange: "bytes 0-15/not-a-number", wantErr: "parse total size:"},
|
||||
{name: "zero total size", contentRange: "bytes 0-0/0", wantErr: "invalid total size: 0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseTotalSize(tt.contentRange)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("parseTotalSize() error = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTotalSize() unexpected error = %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseTotalSize() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcuts(t *testing.T) {
|
||||
var commands []string
|
||||
for _, shortcut := range Shortcuts() {
|
||||
|
||||
@@ -28,7 +28,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id) user open_id (ou_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)"},
|
||||
{Name: "end", Desc: "end time (ISO 8601)"},
|
||||
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
@@ -57,11 +57,21 @@ var ImChatMessageList = common.Shortcut{
|
||||
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
|
||||
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
|
||||
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
|
||||
// Under bot identity, --user-id is not supported; require --chat-id only.
|
||||
if runtime.IsBot() {
|
||||
if runtime.Str("user-id") != "" {
|
||||
return common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
}
|
||||
if runtime.Str("chat-id") == "" {
|
||||
return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)")
|
||||
}
|
||||
} else {
|
||||
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
|
||||
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
|
||||
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate ID formats
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,6 +68,9 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid output path: %s", err)
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(relPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath)
|
||||
if err != nil {
|
||||
@@ -102,7 +106,13 @@ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
const defaultIMResourceDownloadTimeout = 120 * time.Second
|
||||
const (
|
||||
defaultIMResourceDownloadTimeout = 120 * time.Second
|
||||
probeChunkSize = int64(128 * 1024)
|
||||
normalChunkSize = int64(8 * 1024 * 1024)
|
||||
imDownloadRequestRetries = 2
|
||||
imDownloadRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
var imMimeToExt = map[string]string{
|
||||
"image/png": ".png",
|
||||
@@ -135,10 +145,199 @@ var imMimeToExt = map[string]string{
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
}
|
||||
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, safePath string) (string, int64, error) {
|
||||
type rangeChunkReader struct {
|
||||
ctx context.Context
|
||||
runtime *common.RuntimeContext
|
||||
messageID string
|
||||
fileKey string
|
||||
fileType string
|
||||
totalSize int64
|
||||
delivered int64
|
||||
current io.ReadCloser
|
||||
nextOffset int64
|
||||
}
|
||||
|
||||
func newRangeChunkReader(
|
||||
ctx context.Context,
|
||||
runtime *common.RuntimeContext,
|
||||
messageID, fileKey, fileType string,
|
||||
probeBody io.ReadCloser,
|
||||
totalSize int64,
|
||||
) *rangeChunkReader {
|
||||
return &rangeChunkReader{
|
||||
ctx: ctx,
|
||||
runtime: runtime,
|
||||
messageID: messageID,
|
||||
fileKey: fileKey,
|
||||
fileType: fileType,
|
||||
totalSize: totalSize,
|
||||
current: probeBody,
|
||||
nextOffset: probeChunkSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rangeChunkReader) Read(p []byte) (int, error) {
|
||||
for {
|
||||
if r.current != nil {
|
||||
n, err := r.current.Read(p)
|
||||
r.delivered += int64(n)
|
||||
|
||||
if r.delivered > r.totalSize {
|
||||
if err == io.EOF {
|
||||
closeErr := r.current.Close()
|
||||
r.current = nil
|
||||
if closeErr != nil {
|
||||
return 0, closeErr
|
||||
}
|
||||
}
|
||||
return 0, output.ErrNetwork("chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
|
||||
}
|
||||
|
||||
switch err {
|
||||
case nil:
|
||||
return n, nil
|
||||
case io.EOF:
|
||||
closeErr := r.current.Close()
|
||||
r.current = nil
|
||||
if closeErr != nil {
|
||||
return n, closeErr
|
||||
}
|
||||
if r.delivered == r.totalSize {
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
default:
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
|
||||
if r.nextOffset >= r.totalSize {
|
||||
if r.delivered == r.totalSize {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return 0, output.ErrNetwork("file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
|
||||
}
|
||||
|
||||
end := min(r.nextOffset+normalChunkSize-1, r.totalSize-1)
|
||||
resp, err := doIMResourceDownloadRequest(r.ctx, r.runtime, r.messageID, r.fileKey, r.fileType, map[string]string{
|
||||
"Range": fmt.Sprintf("bytes=%d-%d", r.nextOffset, end),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
defer resp.Body.Close()
|
||||
return 0, downloadResponseError(resp)
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return 0, output.ErrNetwork("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
r.current = resp.Body
|
||||
r.nextOffset = end + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rangeChunkReader) Close() error {
|
||||
if r.current == nil {
|
||||
return nil
|
||||
}
|
||||
err := r.current.Close()
|
||||
r.current = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func initialIMResourceDownloadHeaders(fileType string) map[string]string {
|
||||
if fileType != "file" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{
|
||||
"Range": fmt.Sprintf("bytes=0-%d", probeChunkSize-1),
|
||||
}
|
||||
}
|
||||
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string) (string, int64, error) {
|
||||
downloadResp, err := doIMResourceDownloadRequest(ctx, runtime, messageID, fileKey, fileType, initialIMResourceDownloadHeaders(fileType))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
defer downloadResp.Body.Close()
|
||||
return "", 0, downloadResponseError(downloadResp)
|
||||
}
|
||||
|
||||
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"))
|
||||
|
||||
var (
|
||||
body io.ReadCloser
|
||||
sizeBytes int64
|
||||
)
|
||||
switch downloadResp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
|
||||
if err != nil {
|
||||
downloadResp.Body.Close()
|
||||
return "", 0, output.ErrNetwork("invalid Content-Range header on range response: %s", err)
|
||||
}
|
||||
body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize)
|
||||
sizeBytes = totalSize
|
||||
|
||||
case http.StatusOK:
|
||||
body = downloadResp.Body
|
||||
sizeBytes = downloadResp.ContentLength
|
||||
|
||||
default:
|
||||
downloadResp.Body.Close()
|
||||
return "", 0, output.ErrNetwork("unexpected status code: %d", downloadResp.StatusCode)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: downloadResp.Header.Get("Content-Type"),
|
||||
ContentLength: sizeBytes,
|
||||
}, body)
|
||||
if err != nil {
|
||||
return "", 0, common.WrapSaveErrorByCategory(err, "api_error")
|
||||
}
|
||||
if sizeBytes >= 0 && result.Size() != sizeBytes {
|
||||
return "", 0, output.ErrNetwork("file size mismatch: expected %d, got %d", sizeBytes, result.Size())
|
||||
}
|
||||
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
|
||||
if resolveErr != nil || savedPath == "" {
|
||||
savedPath = finalPath
|
||||
}
|
||||
return savedPath, result.Size(), nil
|
||||
}
|
||||
|
||||
func resolveIMResourceDownloadPath(safePath, contentType string) string {
|
||||
if filepath.Ext(safePath) != "" {
|
||||
return safePath
|
||||
}
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := imMimeToExt[mimeType]; ok {
|
||||
return safePath + ext
|
||||
}
|
||||
return safePath
|
||||
}
|
||||
|
||||
func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType string, headers map[string]string) (*http.Response, error) {
|
||||
query := larkcore.QueryParams{}
|
||||
query.Set("type", fileType)
|
||||
downloadResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
|
||||
headerValues := make(http.Header, len(headers))
|
||||
for key, value := range headers {
|
||||
headerValues.Set(key, value)
|
||||
}
|
||||
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/im/v1/messages/:message_id/resources/:file_key",
|
||||
PathParams: larkcore.PathParams{
|
||||
@@ -146,44 +345,73 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
"file_key": fileKey,
|
||||
},
|
||||
QueryParams: query,
|
||||
}, client.WithTimeout(defaultIMResourceDownloadTimeout))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer downloadResp.Body.Close()
|
||||
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(downloadResp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body)))
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= imDownloadRequestRetries; attempt++ {
|
||||
resp, err := runtime.DoAPIStream(ctx, req, client.WithTimeout(defaultIMResourceDownloadTimeout), client.WithHeaders(headerValues))
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode)
|
||||
}
|
||||
|
||||
// Auto-detect extension from Content-Type if missing
|
||||
finalPath := safePath
|
||||
if filepath.Ext(safePath) == "" {
|
||||
contentType := downloadResp.Header.Get("Content-Type")
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := imMimeToExt[mimeType]; ok {
|
||||
finalPath = safePath + ext
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == imDownloadRequestRetries {
|
||||
break
|
||||
}
|
||||
sleepIMDownloadRetry(ctx, attempt)
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: downloadResp.Header.Get("Content-Type"),
|
||||
ContentLength: downloadResp.ContentLength,
|
||||
}, downloadResp.Body)
|
||||
if err != nil {
|
||||
return "", 0, output.Errorf(output.ExitInternal, "api_error", "%s",
|
||||
common.WrapSaveError(err, "unsafe output path", "cannot create parent directory", "cannot create file"))
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
|
||||
if resolveErr != nil {
|
||||
// Save succeeded — file is on disk. Fall back to the relative path
|
||||
// rather than returning an error for a successfully written file.
|
||||
savedPath = finalPath
|
||||
}
|
||||
return savedPath, result.Size(), nil
|
||||
return nil, output.ErrNetwork("download request failed")
|
||||
}
|
||||
|
||||
func sleepIMDownloadRetry(ctx context.Context, attempt int) {
|
||||
delay := imDownloadRetryDelay * (1 << uint(attempt))
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
func downloadResponseError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func parseTotalSize(contentRange string) (int64, error) {
|
||||
contentRange = strings.TrimSpace(contentRange)
|
||||
if contentRange == "" {
|
||||
return 0, fmt.Errorf("content-range is empty")
|
||||
}
|
||||
if !strings.HasPrefix(contentRange, "bytes ") {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
}
|
||||
if parts[0] == "*" {
|
||||
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
|
||||
}
|
||||
if parts[1] == "*" {
|
||||
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange)
|
||||
}
|
||||
|
||||
totalSize, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse total size: %w", err)
|
||||
}
|
||||
if totalSize <= 0 {
|
||||
return 0, fmt.Errorf("invalid total size: %d", totalSize)
|
||||
}
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ var MailWatch = common.Shortcut{
|
||||
Command: "+watch",
|
||||
Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
@@ -192,36 +192,23 @@ var MailWatch = common.Shortcut{
|
||||
msgFormat := runtime.Str("msg-format")
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir != "" {
|
||||
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot expand ~: %w", err)
|
||||
}
|
||||
if outputDir == "~" {
|
||||
outputDir = home
|
||||
} else {
|
||||
outputDir = filepath.Join(home, outputDir[2:])
|
||||
}
|
||||
} else if filepath.IsAbs(outputDir) {
|
||||
outputDir = filepath.Clean(outputDir)
|
||||
} else {
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
|
||||
// literal relative path (creating a directory named "~"), which is
|
||||
// confusing. This also covers ~user/path forms.
|
||||
if strings.HasPrefix(outputDir, "~") {
|
||||
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
|
||||
}
|
||||
// Resolve symlinks on the output directory so all writes use the real
|
||||
// filesystem path. This prevents a symlink from redirecting writes to
|
||||
// an unintended location (TOCTOU mitigation).
|
||||
// Enforce CWD containment: reject absolute paths, path traversal,
|
||||
// and symlink escapes. SafeOutputPath returns a resolved absolute path
|
||||
// under CWD, preventing writes to arbitrary system directories.
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
|
||||
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(outputDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve output directory: %w", err)
|
||||
}
|
||||
outputDir = resolved
|
||||
}
|
||||
labelIDsInput := runtime.Str("label-ids")
|
||||
folderIDsInput := runtime.Str("folder-ids")
|
||||
|
||||
333
shortcuts/sheets/sheet_dropdown.go
Normal file
333
shortcuts/sheets/sheet_dropdown.go
Normal file
@@ -0,0 +1,333 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func dataValidationBasePath(token string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
|
||||
validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func dataValidationSheetPath(token, sheetID string) string {
|
||||
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
|
||||
var typed []string
|
||||
if err := json.Unmarshal([]byte(value), &typed); err != nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
|
||||
}
|
||||
if typed == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
|
||||
}
|
||||
arr := make([]interface{}, len(typed))
|
||||
for i, s := range typed {
|
||||
arr[i] = s
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ranges) == 0 {
|
||||
return nil, common.FlagErrorf("--ranges must not be empty")
|
||||
}
|
||||
for i, r := range ranges {
|
||||
s, _ := r.(string)
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
|
||||
}
|
||||
}
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(condValues) == 0 {
|
||||
return nil, common.FlagErrorf("--condition-values must not be empty")
|
||||
}
|
||||
|
||||
dv := map[string]interface{}{
|
||||
"conditionValues": condValues,
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("multiple") {
|
||||
opts["multipleValues"] = runtime.Bool("multiple")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("highlight") {
|
||||
opts["highlightValidData"] = runtime.Bool("highlight")
|
||||
}
|
||||
if runtime.Str("colors") != "" {
|
||||
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(colors) != len(condValues) {
|
||||
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
|
||||
}
|
||||
opts["colors"] = colors
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
dv["options"] = opts
|
||||
}
|
||||
|
||||
return dv, nil
|
||||
}
|
||||
|
||||
// SheetSetDropdown sets dropdown list validation on a range.
|
||||
var SheetSetDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+set-dropdown",
|
||||
Description: "Set dropdown list on a cell range",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
|
||||
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
|
||||
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
|
||||
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
|
||||
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
dv, _ := buildDropdownBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
|
||||
Body(map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
dv, err := buildDropdownBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetUpdateDropdown updates dropdown list settings for given ranges.
|
||||
var SheetUpdateDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-dropdown",
|
||||
Description: "Update dropdown list settings",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "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: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
|
||||
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
|
||||
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
|
||||
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
|
||||
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validateRangesFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
dv, _ := buildDropdownBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
|
||||
Body(map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dv, err := buildDropdownBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
|
||||
map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
"dataValidation": dv,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetGetDropdown queries dropdown list settings for a range.
|
||||
var SheetGetDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-dropdown",
|
||||
Description: "Get dropdown list settings for a range",
|
||||
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: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
|
||||
Set("token", token).Set("range", runtime.Str("range"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
}, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
|
||||
var SheetDeleteDropdown = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-dropdown",
|
||||
Description: "Delete dropdown list from cell ranges",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateDropdownToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateRangesFlag(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
dvRanges := make([]interface{}, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
|
||||
Body(map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dvRanges := make([]interface{}, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
552
shortcuts/sheets/sheet_dropdown_test.go
Normal file
552
shortcuts/sheets/sheet_dropdown_test.go
Normal file
@@ -0,0 +1,552 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── SetDropdown ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSetDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": "not-json",
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"mixed types", `["ok", 1, null]`},
|
||||
{"all numbers", `[1, 2, 3]`},
|
||||
{"null literal", `null`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": tc.input,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
|
||||
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateInvalidColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "bad-json",
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
|
||||
t.Fatalf("expected colors JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "A2:A100", "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `[]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
|
||||
"colors": `["#FF0000"]`,
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetSetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors length") {
|
||||
t.Fatalf("expected length mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": true, "highlight": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"POST"`) {
|
||||
t.Fatalf("DryRun should use POST: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidation`) {
|
||||
t.Fatalf("DryRun missing dataValidation: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"dataValidationType":"list"`) {
|
||||
t.Fatalf("DryRun missing dataValidationType: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
|
||||
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
|
||||
"--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)
|
||||
}
|
||||
dv, _ := body["dataValidation"].(map[string]interface{})
|
||||
opts, _ := dv["options"].(map[string]interface{})
|
||||
if opts["multipleValues"] != true {
|
||||
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
|
||||
}
|
||||
if opts["highlightValidData"] != true {
|
||||
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetDropdown, []string{
|
||||
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateDropdown ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
|
||||
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": "not-json", "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `[]`, "condition-values": `["opt1"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
|
||||
"colors": "{not-array}",
|
||||
}, map[string]bool{"multiple": false, "highlight": true})
|
||||
err := SheetUpdateDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
|
||||
t.Fatalf("expected colors JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
|
||||
"colors": "",
|
||||
}, map[string]bool{"multiple": false, "highlight": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `sheet1`) {
|
||||
t.Fatalf("DryRun missing sheet_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
|
||||
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
|
||||
"--condition-values", `["new1","new2"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
|
||||
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
|
||||
"--condition-values", `["opt1"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetDropdown ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
|
||||
}, nil)
|
||||
err := SheetGetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
|
||||
}, nil)
|
||||
err := SheetGetDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidation`) {
|
||||
t.Fatalf("DryRun missing dataValidation path: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
|
||||
"dataValidations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"dataValidationType": "list",
|
||||
"conditionValues": []interface{}{"opt1", "opt2"},
|
||||
"ranges": []interface{}{"s1!A2:A100"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetDropdown, []string{
|
||||
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "s1!A2:A100", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "dataValidations") {
|
||||
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
|
||||
"dataValidations": []interface{}{},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetDropdown, []string{
|
||||
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--range", "s1!A2:A100", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteDropdown ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
|
||||
t.Fatalf("expected range validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
|
||||
t.Fatalf("expected empty error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
|
||||
}, nil)
|
||||
err := SheetDeleteDropdown.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
|
||||
t.Fatalf("expected JSON array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dataValidationRanges`) {
|
||||
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"rangeResults": []interface{}{
|
||||
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--ranges", `["s1!A2:A100"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "rangeResults") {
|
||||
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
|
||||
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--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)
|
||||
}
|
||||
dvRanges, _ := body["dataValidationRanges"].([]interface{})
|
||||
if len(dvRanges) != 2 {
|
||||
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDropdownWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
|
||||
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--ranges", `["s1!A2:A100"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suppress unused import for bytes in case the test helpers already import it
|
||||
var _ = (*bytes.Buffer)(nil)
|
||||
239
shortcuts/sheets/sheet_filter_view.go
Normal file
239
shortcuts/sheets/sheet_filter_view.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// 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 filterViewBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func filterViewItemPath(token, sheetID, filterViewID string) string {
|
||||
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
|
||||
}
|
||||
|
||||
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
var SheetCreateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-filter-view",
|
||||
Description: "Create a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
|
||||
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
|
||||
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-filter-view",
|
||||
Description: "Update a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "range", Desc: "new filter range"},
|
||||
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("range") &&
|
||||
!runtime.Cmd.Flags().Changed("filter-view-name") {
|
||||
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetListFilterViews = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-filter-views",
|
||||
Description: "List all filter views in a sheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetGetFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-filter-view",
|
||||
Description: "Get a filter view by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-filter-view",
|
||||
Description: "Delete a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
261
shortcuts/sheets/sheet_filter_view_condition.go
Normal file
261
shortcuts/sheets/sheet_filter_view_condition.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func filterViewConditionBasePath(token, sheetID, filterViewID string) string {
|
||||
return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID))
|
||||
}
|
||||
|
||||
func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID string) string {
|
||||
return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID))
|
||||
}
|
||||
|
||||
var SheetCreateFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-filter-view-condition",
|
||||
Description: "Create a filter condition on a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
|
||||
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
|
||||
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateExpectedFlag(runtime.Str("expected"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, true)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, true)
|
||||
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-filter-view-condition",
|
||||
Description: "Update a filter condition on a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
|
||||
{Name: "compare-type", Desc: "comparison operator"},
|
||||
{Name: "expected", Desc: "filter values JSON array"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("filter-type") &&
|
||||
!runtime.Cmd.Flags().Changed("compare-type") &&
|
||||
!runtime.Cmd.Flags().Changed("expected") {
|
||||
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
return validateExpectedFlag(s)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, false)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
|
||||
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, false)
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetListFilterViewConditions = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-filter-view-conditions",
|
||||
Description: "List all filter conditions of a filter view",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET",
|
||||
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetGetFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-filter-view-condition",
|
||||
Description: "Get a filter condition by column",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
|
||||
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-filter-view-condition",
|
||||
Description: "Delete a filter condition from a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
|
||||
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateExpectedFlag checks that --expected is a valid JSON array.
|
||||
func validateExpectedFlag(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
||||
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildConditionBody constructs the request body for condition create/update.
|
||||
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if includeConditionID {
|
||||
body["condition_id"] = runtime.Str("condition-id")
|
||||
}
|
||||
if s := runtime.Str("filter-type"); s != "" {
|
||||
body["filter_type"] = s
|
||||
}
|
||||
if s := runtime.Str("compare-type"); s != "" {
|
||||
body["compare_type"] = s
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
var arr []interface{}
|
||||
// Validate already ensures this is a valid JSON array.
|
||||
_ = json.Unmarshal([]byte(s), &arr)
|
||||
body["expected"] = arr
|
||||
}
|
||||
return body
|
||||
}
|
||||
628
shortcuts/sheets/sheet_filter_view_test.go
Normal file
628
shortcuts/sheets/sheet_filter_view_test.go
Normal file
@@ -0,0 +1,628 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// ── CreateFilterView ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFilterViewValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "range": "s1!A1:H14",
|
||||
"filter-view-name": "", "filter-view-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFilterView.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "s1!A1:H14",
|
||||
"filter-view-name": "", "filter-view-id": "",
|
||||
}, nil)
|
||||
if err := SheetCreateFilterView.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "range": "sheet1!A1:H14",
|
||||
"filter-view-name": "my view", "filter-view-id": "",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `filter_views`) {
|
||||
t.Fatalf("DryRun URL missing filter_views: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"filter_view_name":"my view"`) {
|
||||
t.Fatalf("DryRun missing name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "pH9hbVcCXA", "range": "sheet1!A1:H14"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
|
||||
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "filter_view_id") {
|
||||
t.Fatalf("stdout missing filter_view_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
|
||||
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFilterView ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"filter-view-id": "pH9hbVcCXA", "range": "sheet1!A1:J20", "filter-view-name": "",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PATCH"`) {
|
||||
t.Fatalf("DryRun should use PATCH: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `pH9hbVcCXA`) {
|
||||
t.Fatalf("DryRun missing filter_view_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewExecuteSuccess(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/filter_views/fv123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv123", "range": "sheet1!A1:J20"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
|
||||
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--range", "sheet1!A1:J20", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewRejectsNoFields(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
|
||||
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when no update fields provided, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFilterViews ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestListFilterViewsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetListFilterViews.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `filter_views/query`) {
|
||||
t.Fatalf("DryRun URL missing query: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewsExecuteSuccess(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/filter_views/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"filter_view_id": "fv1"}},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViews, []string{
|
||||
"+list-filter-views", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fv1") {
|
||||
t.Fatalf("stdout missing fv1: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetFilterView ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fv123`) {
|
||||
t.Fatalf("DryRun missing filter_view_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewExecuteSuccess(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/filter_views/fv123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterView, []string{
|
||||
"+get-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteFilterView ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteFilterViewDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterView.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewExecuteSuccess(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/filter_views/fv123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
|
||||
"+delete-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CreateFilterViewCondition ────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFilterViewConditionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "filter-view-id": "fv1",
|
||||
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
|
||||
}, nil)
|
||||
err := SheetCreateFilterViewCondition.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
|
||||
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `conditions`) {
|
||||
t.Fatalf("DryRun URL missing conditions: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"condition_id":"E"`) {
|
||||
t.Fatalf("DryRun missing condition_id: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"filter_type":"number"`) {
|
||||
t.Fatalf("DryRun missing filter_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
|
||||
"--expected", `["6"]`, "--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)
|
||||
}
|
||||
if body["condition_id"] != "E" {
|
||||
t.Fatalf("unexpected condition_id: %v", body["condition_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
|
||||
"condition-id": "E", "filter-type": "number", "compare-type": "between", "expected": `["2","10"]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"compare_type":"between"`) {
|
||||
t.Fatalf("DryRun missing compare_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewConditionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
|
||||
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
|
||||
"--filter-type", "number", "--compare-type", "between", "--expected", `["2","10"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
|
||||
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when no update fields provided, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFilterViewConditions ─────────────────────────────────────────────────
|
||||
|
||||
func TestListFilterViewConditionsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetListFilterViewConditions.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `conditions/query`) {
|
||||
t.Fatalf("DryRun URL missing conditions/query: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewConditionsExecuteSuccess(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/filter_views/fv1/conditions/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"condition_id": "E"}},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
|
||||
"+list-filter-view-conditions", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetFilterViewCondition ───────────────────────────────────────────────────
|
||||
|
||||
func TestGetFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"filter-view-id": "fv1", "condition-id": "E",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewConditionExecuteSuccess(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/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
|
||||
"+get-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteFilterViewCondition ────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteFilterViewConditionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"filter-view-id": "fv1", "condition-id": "E",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterViewCondition.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewConditionExecuteSuccess(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/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
|
||||
"+delete-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL flag coverage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFilterViewWithURL(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/filter_views",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
|
||||
"+create-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewsWithURL(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/filter_views/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViews, []string{
|
||||
"+list-filter-views", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewWithURL(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/filter_views/fv1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterView, []string{
|
||||
"+get-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewWithURL(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/filter_views/fv1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
|
||||
"+update-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--range", "sheet1!A1:J20", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewWithURL(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/filter_views/fv1",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
|
||||
"+delete-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionWithURL(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/filter_views/fv1/conditions",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
|
||||
"--expected", `["6"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewConditionWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
|
||||
"+update-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
|
||||
"--filter-type", "number", "--expected", `["5"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilterViewConditionsWithURL(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/filter_views/fv1/conditions/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
|
||||
"+list-filter-view-conditions", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterViewConditionWithURL(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/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "E"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
|
||||
"+get-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilterViewConditionWithURL(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/filter_views/fv1/conditions/E",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
|
||||
"+delete-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── --expected validation rejects non-array input ────────────────────────────
|
||||
|
||||
func TestCreateFilterViewConditionRejectsNonArrayExpected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"plain string", "hello"},
|
||||
{"JSON object", `{"key":"val"}`},
|
||||
{"JSON number", "42"},
|
||||
{"JSON string", `"hello"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "A", "--filter-type", "text", "--compare-type", "contains",
|
||||
"--expected", tc.expected, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --expected=%q, got nil", tc.expected)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--expected must be a JSON array") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,19 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetUpdateDimension,
|
||||
SheetMoveDimension,
|
||||
SheetDeleteDimension,
|
||||
SheetCreateFilterView,
|
||||
SheetUpdateFilterView,
|
||||
SheetListFilterViews,
|
||||
SheetGetFilterView,
|
||||
SheetDeleteFilterView,
|
||||
SheetCreateFilterViewCondition,
|
||||
SheetUpdateFilterViewCondition,
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
SheetSetDropdown,
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
CreateTask,
|
||||
UpdateTask,
|
||||
SetAncestorTask,
|
||||
CommentTask,
|
||||
CompleteTask,
|
||||
ReopenTask,
|
||||
@@ -230,7 +231,11 @@ func Shortcuts() []common.Shortcut {
|
||||
FollowersTask,
|
||||
ReminderTask,
|
||||
GetMyTasks,
|
||||
GetRelatedTasks,
|
||||
SearchTask,
|
||||
SubscribeTaskEvent,
|
||||
CreateTasklist,
|
||||
SearchTasklist,
|
||||
AddTaskToTasklist,
|
||||
MembersTasklist,
|
||||
}
|
||||
|
||||
155
shortcuts/task/task_get_related_tasks.go
Normal file
155
shortcuts/task/task_get_related_tasks.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
relatedTasksDefaultPageLimit = 20
|
||||
relatedTasksMaxPageLimit = 40
|
||||
relatedTasksPageSize = 100
|
||||
)
|
||||
|
||||
var GetRelatedTasks = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+get-related-tasks",
|
||||
Description: "list tasks related to me",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
|
||||
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
|
||||
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"page_size": relatedTasksPageSize,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
params["completed"] = false
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/task_v2/list_related_task").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
queryParams.Set("completed", "false")
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = relatedTasksDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
if pageLimit > relatedTasksMaxPageLimit {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
|
||||
var allItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "list related tasks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", lastPageToken)
|
||||
}
|
||||
|
||||
userOpenID := runtime.UserOpenId()
|
||||
filtered := make([]map[string]interface{}, 0, len(allItems))
|
||||
for _, item := range allItems {
|
||||
task, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if runtime.Bool("created-by-me") {
|
||||
creator, _ := task["creator"].(map[string]interface{})
|
||||
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, outputRelatedTask(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": filtered,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
|
||||
if len(filtered) == 0 {
|
||||
fmt.Fprintln(w, "No related tasks found.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
|
||||
members, _ := task["members"].([]interface{})
|
||||
for _, member := range members {
|
||||
memberObj, _ := member.(map[string]interface{})
|
||||
role, _ := memberObj["role"].(string)
|
||||
id, _ := memberObj["id"].(string)
|
||||
if strings.EqualFold(role, "follower") && id == userOpenID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
207
shortcuts/task/task_get_related_tasks_test.go
Normal file
207
shortcuts/task/task_get_related_tasks_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestTaskFollowedBy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
userOpenID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "contains follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
map[string]interface{}{"id": "ou_2", "role": "follower"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_3",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := taskFollowedBy(tt.task, tt.userOpenID)
|
||||
if got != tt.want {
|
||||
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with page token and incomplete filter",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("include-complete", "false")
|
||||
_ = cmd.Flags().Set("page-token", "pt_001")
|
||||
},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
|
||||
},
|
||||
{
|
||||
name: "default query params",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Bool("include-complete", true, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
out := GetRelatedTasks.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json created by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "done",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123",
|
||||
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
|
||||
},
|
||||
{
|
||||
name: "pretty pagination followed by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "pt_2",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-1",
|
||||
"summary": "Task One",
|
||||
"url": "https://example.com/task-1",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "page_token=pt_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-2",
|
||||
"summary": "Task Two",
|
||||
"url": "https://example.com/task-2",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Task One", "Task Two"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := GetRelatedTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
247
shortcuts/task/task_query_helpers.go
Normal file
247
shortcuts/task/task_query_helpers.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func splitAndTrimCSV(input string) []string {
|
||||
parts := strings.Split(input, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startMillis, endMillis string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
|
||||
}
|
||||
hasStart = true
|
||||
startMillis = startSec + "000"
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endMillis = endSec + "000"
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startMillis, endMillis, nil
|
||||
}
|
||||
|
||||
func parseTimeRangeRFC3339(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startTime, endTime string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
|
||||
}
|
||||
hasStart = true
|
||||
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
func formatTaskDateTimeMillis(msStr string) string {
|
||||
if msStr == "" || msStr == "0" {
|
||||
return ""
|
||||
}
|
||||
ms, err := strconv.ParseInt(msStr, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Local().Format(time.DateTime)
|
||||
}
|
||||
|
||||
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
|
||||
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
|
||||
out["updated_at"] = updated
|
||||
}
|
||||
}
|
||||
if dueObj, ok := task["due"].(map[string]interface{}); ok {
|
||||
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
|
||||
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
|
||||
out["due_at"] = dueAt
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"description": task["description"],
|
||||
"status": task["status"],
|
||||
"source": task["source"],
|
||||
"mode": task["mode"],
|
||||
"subtask_count": task["subtask_count"],
|
||||
"tasklists": task["tasklists"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if creator, ok := task["creator"].(map[string]interface{}); ok {
|
||||
out["creator"] = creator
|
||||
}
|
||||
if members, ok := task["members"].([]interface{}); ok {
|
||||
out["members"] = members
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
|
||||
timeRange := map[string]interface{}{}
|
||||
if start != "" {
|
||||
timeRange["start_time"] = start
|
||||
}
|
||||
if end != "" {
|
||||
timeRange["end_time"] = end
|
||||
}
|
||||
if len(timeRange) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{key: timeRange}
|
||||
}
|
||||
|
||||
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
|
||||
if strings.TrimSpace(query) != "" {
|
||||
return nil
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
return nil
|
||||
}
|
||||
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
|
||||
}
|
||||
|
||||
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
|
||||
var b strings.Builder
|
||||
for i, item := range items {
|
||||
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
|
||||
if status, _ := item["status"].(string); status != "" {
|
||||
fmt.Fprintf(&b, " Status: %s\n", status)
|
||||
}
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(&b, " Created: %s\n", created)
|
||||
}
|
||||
if completed, _ := item["completed_at"].(string); completed != "" {
|
||||
fmt.Fprintf(&b, " Completed: %s\n", completed)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(&b, " URL: %s\n", urlVal)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if hasMore && pageToken != "" {
|
||||
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
286
shortcuts/task/task_query_helpers_test.go
Normal file
286
shortcuts/task/task_query_helpers_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitAndTrimCSV(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
|
||||
{name: "empty input", input: "", want: []string{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := splitAndTrimCSV(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTaskSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "with timestamps and due",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "summary",
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"created_at": "1775174400000",
|
||||
"due": map[string]interface{}{
|
||||
"timestamp": "1775174400000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with completed and updated",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "done",
|
||||
"url": "https://example.com/task-456",
|
||||
"completed_at": "1775174400000",
|
||||
"updated_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := outputTaskSummary(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected summary output: %#v", got)
|
||||
}
|
||||
if got["url"] == "" {
|
||||
t.Fatalf("expected url in output, got %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
for _, tt := range timeTests {
|
||||
t.Run("parse:"+tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeMillis(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
|
||||
}
|
||||
if tt.wantStart == "" && start != "" {
|
||||
t.Fatalf("start = %q, want empty", start)
|
||||
}
|
||||
if tt.wantEnd == "" && end != "" {
|
||||
t.Fatalf("end = %q, want empty", end)
|
||||
}
|
||||
if tt.wantStart == "non-empty" && start == "" {
|
||||
t.Fatalf("start should not be empty")
|
||||
}
|
||||
if tt.wantEnd == "non-empty" && end == "" {
|
||||
t.Fatalf("end should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
filterTests := []struct {
|
||||
name string
|
||||
query string
|
||||
filter map[string]interface{}
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
|
||||
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
|
||||
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
|
||||
}
|
||||
for _, tt := range filterTests {
|
||||
t.Run("filter:"+tt.name, func(t *testing.T) {
|
||||
err := requireSearchFilter(tt.query, tt.filter, "search")
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
|
||||
outputTests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "todo",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"creator": map[string]interface{}{"id": "ou_1"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
|
||||
"created_at": "1775174400000",
|
||||
"completed_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "Minimal",
|
||||
"url": "https://example.com/task-456",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range outputTests {
|
||||
t.Run("output:"+tt.name, func(t *testing.T) {
|
||||
got := outputRelatedTask(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected related task output: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rangeTests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "empty range", start: "", end: "", wantNil: true},
|
||||
{name: "full range", start: "1", end: "2", wantNil: false},
|
||||
}
|
||||
for _, tt := range rangeTests {
|
||||
t.Run("range:"+tt.name, func(t *testing.T) {
|
||||
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
|
||||
if tt.wantNil && got != nil {
|
||||
t.Fatalf("expected nil, got %#v", got)
|
||||
}
|
||||
if !tt.wantNil && got == nil {
|
||||
t.Fatalf("expected range filter, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRelatedTasksPretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []map[string]interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "includes next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
|
||||
},
|
||||
hasMore: true,
|
||||
pageToken: "pt_123",
|
||||
wantParts: []string{"Related Task", "Next page token: pt_123"},
|
||||
},
|
||||
{
|
||||
name: "without next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-456", "summary": "Another Task"},
|
||||
},
|
||||
hasMore: false,
|
||||
pageToken: "",
|
||||
wantParts: []string{"Another Task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range timeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeRFC3339(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
|
||||
}
|
||||
if tt.wantStart == "rfc3339" {
|
||||
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
|
||||
t.Fatalf("expected RFC3339 start, got %q", start)
|
||||
}
|
||||
} else if start != tt.wantStart {
|
||||
t.Fatalf("unexpected start: %q", start)
|
||||
}
|
||||
if tt.wantEnd == "rfc3339" {
|
||||
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
|
||||
t.Fatalf("expected RFC3339 end, got %q", end)
|
||||
}
|
||||
} else if end != tt.wantEnd {
|
||||
t.Fatalf("unexpected end: %q", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
222
shortcuts/task/task_search.go
Normal file
222
shortcuts/task/task_search.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
taskSearchDefaultPageLimit = 20
|
||||
taskSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+search",
|
||||
Description: "search tasks",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
|
||||
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
|
||||
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
|
||||
{Name: "follower", Desc: "follower open_ids, comma-separated"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTaskSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = taskSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > taskSearchMaxPageLimit {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
enriched := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
taskID, _ := itemMap["id"].(string)
|
||||
if taskID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := getTaskDetail(runtime, taskID)
|
||||
if err != nil {
|
||||
metaData, _ := itemMap["meta_data"].(map[string]interface{})
|
||||
appLink, _ := metaData["app_link"].(string)
|
||||
enriched = append(enriched, map[string]interface{}{
|
||||
"guid": taskID,
|
||||
"url": truncateTaskURL(appLink),
|
||||
})
|
||||
continue
|
||||
}
|
||||
enriched = append(enriched, outputTaskSummary(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": enriched,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No tasks found.")
|
||||
return
|
||||
}
|
||||
for i, item := range enriched {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(w, " Created: %s\n", created)
|
||||
}
|
||||
if dueAt, _ := item["due_at"].(string); dueAt != "" {
|
||||
fmt.Fprintf(w, " Due: %s\n", dueAt)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["creator_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
|
||||
filter["assignee_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
|
||||
filter["follower_ids"] = ids
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("completed") {
|
||||
filter["is_completed"] = runtime.Bool("completed")
|
||||
}
|
||||
if dueRange := runtime.Str("due"); dueRange != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(dueRange)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
|
||||
}
|
||||
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
|
||||
mergeIntoFilter(filter, dueFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
if task == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
300
shortcuts/task/task_search_test.go
Normal file
300
shortcuts/task/task_search_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTaskSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantErr bool
|
||||
check func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "query creator due and page token",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "release")
|
||||
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
|
||||
_ = cmd.Flags().Set("completed", "true")
|
||||
_ = cmd.Flags().Set("due", "-1d,+1d")
|
||||
_ = cmd.Flags().Set("page-token", "pt_123")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
dueTime := filter["due_time"].(map[string]interface{})
|
||||
if body["query"] != "release" || body["page_token"] != "pt_123" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
startTime, _ := dueTime["start_time"].(string)
|
||||
endTime, _ := dueTime["end_time"].(string)
|
||||
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
||||
t.Fatalf("unexpected due_time: %#v", dueTime)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requires query or filter",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "assignee follower and incomplete",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("assignee", "ou_assignee")
|
||||
_ = cmd.Flags().Set("follower", "ou_follower")
|
||||
_ = cmd.Flags().Set("completed", "false")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
if filter["is_completed"] != false {
|
||||
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().Bool("completed", false, "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildTaskSearchBody() error = %v", err)
|
||||
}
|
||||
tt.check(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "demo")
|
||||
_ = cmd.Flags().Set("page-token", "pt_demo")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid due",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("due", "bad-time")
|
||||
},
|
||||
wantParts: []string{"error:"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().Bool("completed", false, "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
if !strings.Contains(tt.name, "error") {
|
||||
if err := SearchTask.Validate(nil, runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json success",
|
||||
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
|
||||
},
|
||||
{
|
||||
name: "fallback to app link",
|
||||
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-999",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
|
||||
},
|
||||
{
|
||||
name: "empty pretty with pagination",
|
||||
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"No tasks found."},
|
||||
},
|
||||
{
|
||||
name: "pretty with next page token",
|
||||
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "pt_next",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-321",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := SearchTask
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
shortcuts/task/task_set_ancestor.go
Normal file
84
shortcuts/task/task_set_ancestor.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SetAncestorTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+set-ancestor",
|
||||
Description: "set or clear a task ancestor",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task guid to update", Required: true},
|
||||
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
taskID := url.PathEscape(runtime.Str("task-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskID := runtime.Str("task-id")
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
|
||||
QueryParams: queryParams,
|
||||
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
|
||||
}
|
||||
}
|
||||
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": map[string]interface{}{
|
||||
"guid": taskID,
|
||||
},
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
|
||||
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
|
||||
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
|
||||
} else {
|
||||
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
|
||||
if ancestorID == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ancestor_guid": ancestorID,
|
||||
}
|
||||
}
|
||||
166
shortcuts/task/task_set_ancestor_test.go
Normal file
166
shortcuts/task/task_set_ancestor_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildSetAncestorBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestorID string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
|
||||
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildSetAncestorBody(tt.ancestorID)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
|
||||
}
|
||||
for k, want := range tt.want {
|
||||
if got[k] != want {
|
||||
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskID string
|
||||
ancestor string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with ancestor",
|
||||
taskID: "task-123",
|
||||
ancestor: "task-456",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
|
||||
},
|
||||
{
|
||||
name: "clear ancestor",
|
||||
taskID: "task-123",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("ancestor-id", "", "")
|
||||
_ = cmd.Flags().Set("task-id", tt.taskID)
|
||||
if tt.ancestor != "" {
|
||||
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
|
||||
out := SetAncestorTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json output with ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`},
|
||||
},
|
||||
{
|
||||
name: "pretty output clears ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
|
||||
},
|
||||
{
|
||||
name: "api-level error (code!=0) returns error",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10003,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if out := stdout.String(); out != "" {
|
||||
t.Fatalf("expected empty stdout on error, got: %s", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
shortcuts/task/task_subscribe_event.go
Normal file
58
shortcuts/task/task_subscribe_event.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SubscribeTaskEvent = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+subscribe-event",
|
||||
Description: "subscribe to task events",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/task_v2/task_subscription").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
|
||||
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
|
||||
// Parse and validate the envelope to avoid false-success output.
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
|
||||
}
|
||||
}
|
||||
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{"ok": true}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
131
shortcuts/task/task_subscribe_event_test.go
Normal file
131
shortcuts/task/task_subscribe_event_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSubscribeTaskEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "execute json (user identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute json (bot identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute api error",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 401,
|
||||
"msg": "Unauthorized",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
wantParts: []string{"Unauthorized"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
mode: "dryrun",
|
||||
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
switch tt.mode {
|
||||
case "execute":
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
if tt.register != nil {
|
||||
tt.register(reg)
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
out := err.Error()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("error missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
case "dryrun":
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
|
||||
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
209
shortcuts/task/task_tasklist_search.go
Normal file
209
shortcuts/task/task_tasklist_search.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
tasklistSearchDefaultPageLimit = 20
|
||||
tasklistSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTasklist = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+tasklist-search",
|
||||
Description: "search tasklists",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:tasklist:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasklists/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTasklistSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = tasklistSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > tasklistSearchMaxPageLimit {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/search",
|
||||
Body: currentBody,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "search tasklists")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
tasklists := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
tasklistID, _ := itemMap["id"].(string)
|
||||
if tasklistID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tasklist, err := getTasklistDetail(runtime, tasklistID)
|
||||
if err != nil {
|
||||
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklistID,
|
||||
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
urlVal, _ := tasklist["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklist["guid"],
|
||||
"name": tasklist["name"],
|
||||
"url": urlVal,
|
||||
"creator": tasklist["creator"],
|
||||
})
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": tasklists,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
|
||||
if len(tasklists) == 0 {
|
||||
fmt.Fprintln(w, "No tasklists found.")
|
||||
return
|
||||
}
|
||||
for i, tasklist := range tasklists {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
|
||||
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["user_id"] = ids
|
||||
}
|
||||
if createTime := runtime.Str("create-time"); createTime != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(createTime)
|
||||
if err != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
|
||||
}
|
||||
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
|
||||
mergeIntoFilter(filter, timeFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", "open_id")
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
|
||||
QueryParams: queryParams,
|
||||
})
|
||||
var result map[string]interface{}
|
||||
if err == nil {
|
||||
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
|
||||
}
|
||||
}
|
||||
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasklist, _ := data["tasklist"].(map[string]interface{})
|
||||
if tasklist == nil {
|
||||
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
|
||||
}
|
||||
return tasklist, nil
|
||||
}
|
||||
263
shortcuts/task/task_tasklist_search_test.go
Normal file
263
shortcuts/task/task_tasklist_search_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantErr bool
|
||||
check func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "creator create-time and page token",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("creator", "ou_creator")
|
||||
_ = cmd.Flags().Set("create-time", "-7d,+0d")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
createTime := filter["create_time"].(map[string]interface{})
|
||||
if body["page_token"] != "pt_tl" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if filter["user_id"].([]string)[0] != "ou_creator" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
startTime, _ := createTime["start_time"].(string)
|
||||
endTime, _ := createTime["end_time"].(string)
|
||||
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
||||
t.Fatalf("unexpected create_time: %#v", createTime)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requires query or filter",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildTasklistSearchBody() error = %v", err)
|
||||
}
|
||||
tt.check(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("query", "Q2")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid create time",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("create-time", "bad-time")
|
||||
},
|
||||
wantParts: []string{"error:"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("creator", "", "")
|
||||
cmd.Flags().String("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
if !strings.Contains(tt.name, "error") {
|
||||
if err := SearchTasklist.Validate(nil, runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTasklist.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasklist_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json success",
|
||||
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
|
||||
},
|
||||
{
|
||||
name: "fallback on detail error",
|
||||
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-fallback"`},
|
||||
},
|
||||
{
|
||||
name: "pretty fallback avoids nil name",
|
||||
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
|
||||
},
|
||||
{
|
||||
name: "empty pretty with pagination",
|
||||
args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"No tasklists found."},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
s := SearchTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
|
||||
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
|
||||
{Name: "section-guid", Desc: "section guid"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -40,6 +41,10 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
|
||||
}
|
||||
|
||||
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
|
||||
body["section_guid"] = sectionGuid
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
@@ -57,6 +62,10 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
"tasklist_guid": tasklistGuid,
|
||||
}
|
||||
|
||||
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
|
||||
body["section_guid"] = sectionGuid
|
||||
}
|
||||
|
||||
var successful []map[string]interface{}
|
||||
var failed []map[string]interface{}
|
||||
|
||||
|
||||
43
shortcuts/task/tasklist_add_task_test.go
Normal file
43
shortcuts/task/tasklist_add_task_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAddTaskToTasklist_Success(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := AddTaskToTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
|
||||
t.Errorf("expected tasklist_guid in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all wiki shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WikiMove,
|
||||
WikiNodeCreate,
|
||||
}
|
||||
}
|
||||
|
||||
671
shortcuts/wiki/wiki_move.go
Normal file
671
shortcuts/wiki/wiki_move.go
Normal file
@@ -0,0 +1,671 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
wikiMovePollAttempts = 30
|
||||
wikiMovePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
wikiMoveModeNode = "node"
|
||||
wikiMoveModeDocsToWiki = "docs_to_wiki"
|
||||
)
|
||||
|
||||
var wikiMoveObjectTypes = []string{
|
||||
"doc",
|
||||
"sheet",
|
||||
"bitable",
|
||||
"mindnote",
|
||||
"docx",
|
||||
"file",
|
||||
"slides",
|
||||
}
|
||||
|
||||
// WikiMove moves an existing wiki node inside Wiki or migrates a Drive
|
||||
// document into Wiki with bounded polling for async task completion.
|
||||
var WikiMove = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+move",
|
||||
Description: "Move a wiki node, or move a Drive document into Wiki",
|
||||
Risk: "write",
|
||||
Scopes: []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "node-token", Desc: "wiki node token to move inside Wiki"},
|
||||
{Name: "source-space-id", Desc: "source wiki space ID for --node-token; if omitted, it is resolved from the node token"},
|
||||
{Name: "target-space-id", Desc: "target wiki space ID; required for docs-to-wiki, optional for node move when --target-parent-token is set"},
|
||||
{Name: "target-parent-token", Desc: "target parent wiki node token; if omitted for docs-to-wiki, the document is moved to the target space root"},
|
||||
{Name: "obj-type", Desc: "Drive document type for docs-to-wiki mode", Enum: wikiMoveObjectTypes},
|
||||
{Name: "obj-token", Desc: "Drive document token for docs-to-wiki mode"},
|
||||
{Name: "apply", Type: "bool", Desc: "submit a move request when the caller lacks permission to move the document immediately"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use --node-token to move an existing wiki node inside or across wiki spaces.",
|
||||
"Use --obj-type and --obj-token to move a Drive document into Wiki.",
|
||||
"If docs-to-wiki returns a long-running task, this command polls for a bounded window and then prints a follow-up drive +task_result command.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := readWikiMoveSpec(runtime)
|
||||
// `my_library` is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of letting the API return a confusing error.
|
||||
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
|
||||
}
|
||||
return validateWikiMoveSpec(spec)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return buildWikiMoveDryRun(readWikiMoveSpec(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := readWikiMoveSpec(runtime)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Running wiki move (%s)...\n", spec.Mode())
|
||||
|
||||
out, err := runWikiMove(ctx, wikiMoveAPI{runtime: runtime}, runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type wikiMoveSpec struct {
|
||||
NodeToken string
|
||||
SourceSpaceID string
|
||||
TargetSpaceID string
|
||||
TargetParentToken string
|
||||
ObjType string
|
||||
ObjToken string
|
||||
Apply bool
|
||||
}
|
||||
|
||||
func (spec wikiMoveSpec) Mode() string {
|
||||
if spec.NodeToken != "" {
|
||||
return wikiMoveModeNode
|
||||
}
|
||||
return wikiMoveModeDocsToWiki
|
||||
}
|
||||
|
||||
func (spec wikiMoveSpec) NodeMoveBody() map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if spec.TargetParentToken != "" {
|
||||
body["target_parent_token"] = spec.TargetParentToken
|
||||
}
|
||||
if spec.TargetSpaceID != "" {
|
||||
body["target_space_id"] = spec.TargetSpaceID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (spec wikiMoveSpec) DocsToWikiBody() map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"obj_type": spec.ObjType,
|
||||
"obj_token": spec.ObjToken,
|
||||
}
|
||||
if spec.TargetParentToken != "" {
|
||||
body["parent_wiki_token"] = spec.TargetParentToken
|
||||
}
|
||||
if spec.Apply {
|
||||
body["apply"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
type wikiMoveTaskResult struct {
|
||||
Node *wikiNodeRecord
|
||||
Status int
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
type wikiMoveTaskStatus struct {
|
||||
TaskID string
|
||||
MoveResults []wikiMoveTaskResult
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) Ready() bool {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) Failed() bool {
|
||||
for _, result := range s.MoveResults {
|
||||
if result.Status < 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) Pending() bool {
|
||||
return !s.Ready() && !s.Failed()
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) FirstResult() *wikiMoveTaskResult {
|
||||
if len(s.MoveResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.MoveResults[0]
|
||||
}
|
||||
|
||||
// primaryResult picks the most informative move_result for top-level status
|
||||
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
|
||||
// behind an earlier success, then a still-processing entry, and finally fall
|
||||
// back to the first entry.
|
||||
func (s wikiMoveTaskStatus) primaryResult() *wikiMoveTaskResult {
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status < 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
for i := range s.MoveResults {
|
||||
if s.MoveResults[i].Status > 0 {
|
||||
return &s.MoveResults[i]
|
||||
}
|
||||
}
|
||||
return s.FirstResult()
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) PrimaryStatusCode() int {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
return r.Status
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s wikiMoveTaskStatus) PrimaryStatusLabel() string {
|
||||
if r := s.primaryResult(); r != nil {
|
||||
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.Ready():
|
||||
return "success"
|
||||
case s.Failed():
|
||||
return "failure"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
type wikiMoveDocsResponse struct {
|
||||
WikiToken string
|
||||
TaskID string
|
||||
Applied bool
|
||||
}
|
||||
|
||||
type wikiMoveClient interface {
|
||||
GetNode(ctx context.Context, token string) (*wikiNodeRecord, error)
|
||||
MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error)
|
||||
MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error)
|
||||
GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error)
|
||||
}
|
||||
|
||||
type wikiMoveAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWikiNodeRecord(common.GetMap(data, "node"))
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
|
||||
validate.EncodePathSegment(sourceSpaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
),
|
||||
nil,
|
||||
spec.NodeMoveBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWikiNodeRecord(common.GetMap(data, "node"))
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
|
||||
validate.EncodePathSegment(targetSpaceID),
|
||||
),
|
||||
nil,
|
||||
spec.DocsToWikiBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &wikiMoveDocsResponse{
|
||||
WikiToken: common.GetString(data, "wiki_token"),
|
||||
TaskID: common.GetString(data, "task_id"),
|
||||
Applied: common.GetBool(data, "applied"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiMoveTaskStatus{}, err
|
||||
}
|
||||
return parseWikiMoveTaskStatus(taskID, common.GetMap(data, "task"))
|
||||
}
|
||||
|
||||
func readWikiMoveSpec(runtime *common.RuntimeContext) wikiMoveSpec {
|
||||
return wikiMoveSpec{
|
||||
NodeToken: strings.TrimSpace(runtime.Str("node-token")),
|
||||
SourceSpaceID: strings.TrimSpace(runtime.Str("source-space-id")),
|
||||
TargetSpaceID: strings.TrimSpace(runtime.Str("target-space-id")),
|
||||
TargetParentToken: strings.TrimSpace(runtime.Str("target-parent-token")),
|
||||
ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))),
|
||||
ObjToken: strings.TrimSpace(runtime.Str("obj-token")),
|
||||
Apply: runtime.Bool("apply"),
|
||||
}
|
||||
}
|
||||
|
||||
func validateWikiMoveSpec(spec wikiMoveSpec) error {
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SourceSpaceID, "--source-space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.TargetSpaceID, "--target-space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.TargetParentToken, "--target-parent-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.ObjToken, "--obj-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.NodeToken != "" {
|
||||
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
|
||||
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
|
||||
}
|
||||
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if spec.SourceSpaceID != "" {
|
||||
return output.ErrValidation("--source-space-id can only be used with --node-token")
|
||||
}
|
||||
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
|
||||
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
|
||||
}
|
||||
if spec.ObjType == "" {
|
||||
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
|
||||
}
|
||||
if spec.ObjToken == "" {
|
||||
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
|
||||
}
|
||||
if spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWikiMoveDryRun(spec wikiMoveSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
switch spec.Mode() {
|
||||
case wikiMoveModeNode:
|
||||
step := 1
|
||||
switch {
|
||||
case spec.SourceSpaceID == "" && spec.TargetParentToken != "":
|
||||
dry.Desc("3-step orchestration: resolve source node -> resolve target parent -> move wiki node")
|
||||
case spec.SourceSpaceID == "":
|
||||
dry.Desc("2-step orchestration: resolve source node -> move wiki node")
|
||||
case spec.TargetParentToken != "":
|
||||
dry.Desc("2-step orchestration: resolve target parent -> move wiki node")
|
||||
default:
|
||||
dry.Desc("1-step request: move wiki node")
|
||||
}
|
||||
|
||||
if spec.SourceSpaceID == "" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc(fmt.Sprintf("[%d] Resolve source space from node token", step)).
|
||||
Params(map[string]interface{}{"token": spec.NodeToken})
|
||||
step++
|
||||
}
|
||||
if spec.TargetParentToken != "" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc(fmt.Sprintf("[%d] Resolve target parent node", step)).
|
||||
Params(map[string]interface{}{"token": spec.TargetParentToken})
|
||||
step++
|
||||
}
|
||||
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
|
||||
dryRunWikiMoveSourceSpaceID(spec),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc(fmt.Sprintf("[%d] Move wiki node", step)).
|
||||
Body(spec.NodeMoveBody())
|
||||
case wikiMoveModeDocsToWiki:
|
||||
dry.Desc("2-step orchestration: move Drive document into Wiki -> poll wiki task result when task_id is returned")
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
|
||||
dryRunWikiMoveTargetSpaceID(spec),
|
||||
)).
|
||||
Desc("[1] Move Drive document into Wiki").
|
||||
Body(spec.DocsToWikiBody())
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[2] Poll wiki move task result when async").
|
||||
Set("task_id", "<task_id>").
|
||||
Params(map[string]interface{}{"task_type": "move"})
|
||||
default:
|
||||
dry.Set("error", "unknown wiki move mode")
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func dryRunWikiMoveSourceSpaceID(spec wikiMoveSpec) string {
|
||||
if spec.SourceSpaceID != "" {
|
||||
return validate.EncodePathSegment(spec.SourceSpaceID)
|
||||
}
|
||||
return "<resolved_source_space_id>"
|
||||
}
|
||||
|
||||
func dryRunWikiMoveTargetSpaceID(spec wikiMoveSpec) string {
|
||||
if spec.TargetSpaceID != "" {
|
||||
return validate.EncodePathSegment(spec.TargetSpaceID)
|
||||
}
|
||||
return "<target_space_id>"
|
||||
}
|
||||
|
||||
func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
|
||||
switch spec.Mode() {
|
||||
case wikiMoveModeNode:
|
||||
return runWikiNodeMove(ctx, client, spec)
|
||||
case wikiMoveModeDocsToWiki:
|
||||
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
|
||||
default:
|
||||
return nil, output.ErrValidation("unknown wiki move mode")
|
||||
}
|
||||
}
|
||||
|
||||
func runWikiNodeMove(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (map[string]interface{}, error) {
|
||||
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(ctx, client, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, err := client.MoveNode(ctx, sourceSpaceID, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"mode": wikiMoveModeNode,
|
||||
"source_space_id": sourceSpaceID,
|
||||
"target_space_id": targetSpaceID,
|
||||
}
|
||||
appendWikiNodeOutput(out, node)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (string, string, error) {
|
||||
// Node move requests may start from just a node token and/or a target parent.
|
||||
// Resolve both ends up front so we can fail on space mismatches before sending
|
||||
// the mutation request.
|
||||
sourceSpaceID := spec.SourceSpaceID
|
||||
if sourceSpaceID == "" {
|
||||
sourceNode, err := client.GetNode(ctx, spec.NodeToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
sourceSpaceID, err = requireWikiNodeSpaceID(sourceNode)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
targetSpaceID := spec.TargetSpaceID
|
||||
if spec.TargetParentToken != "" {
|
||||
targetParent, err := client.GetNode(ctx, spec.TargetParentToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parentSpaceID, err := requireWikiNodeSpaceID(targetParent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if targetSpaceID == "" {
|
||||
targetSpaceID = parentSpaceID
|
||||
} else if targetSpaceID != parentSpaceID {
|
||||
return "", "", output.ErrValidation(
|
||||
"--target-space-id %q does not match target parent node space %q",
|
||||
spec.TargetSpaceID,
|
||||
parentSpaceID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if targetSpaceID == "" {
|
||||
targetSpaceID = sourceSpaceID
|
||||
}
|
||||
|
||||
return sourceSpaceID, targetSpaceID, nil
|
||||
}
|
||||
|
||||
func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
|
||||
response, err := client.MoveDocsToWiki(ctx, spec.TargetSpaceID, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"mode": wikiMoveModeDocsToWiki,
|
||||
"obj_type": spec.ObjType,
|
||||
"obj_token": spec.ObjToken,
|
||||
"target_space_id": spec.TargetSpaceID,
|
||||
"target_parent_token": spec.TargetParentToken,
|
||||
}
|
||||
|
||||
// move_docs_to_wiki has three success-shaped responses: immediate completion,
|
||||
// approval-request submission, or an async task that must be polled.
|
||||
switch {
|
||||
case response.WikiToken != "":
|
||||
out["ready"] = true
|
||||
out["failed"] = false
|
||||
out["wiki_token"] = response.WikiToken
|
||||
out["node_token"] = response.WikiToken
|
||||
return out, nil
|
||||
case response.Applied:
|
||||
out["ready"] = false
|
||||
out["failed"] = false
|
||||
out["applied"] = true
|
||||
out["status_msg"] = "move request submitted for approval"
|
||||
return out, nil
|
||||
case response.TaskID != "":
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Docs-to-wiki move is async, polling task %s...\n", response.TaskID)
|
||||
status, ready, err := pollWikiMoveTask(ctx, client, runtime, response.TaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out["task_id"] = response.TaskID
|
||||
out["ready"] = ready
|
||||
out["failed"] = status.Failed()
|
||||
out["status"] = status.PrimaryStatusCode()
|
||||
out["status_msg"] = status.PrimaryStatusLabel()
|
||||
if first := status.FirstResult(); first != nil {
|
||||
appendWikiNodeOutput(out, first.Node)
|
||||
if first.Node != nil && first.Node.NodeToken != "" {
|
||||
out["wiki_token"] = first.Node.NodeToken
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := wikiMoveTaskResultCommand(response.TaskID, runtime.As())
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
|
||||
}
|
||||
}
|
||||
|
||||
func wikiMoveTaskResultCommand(taskID string, identity core.Identity) string {
|
||||
asFlag := string(identity)
|
||||
if asFlag == "" {
|
||||
asFlag = "user"
|
||||
}
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_move --task-id %s --as %s", taskID, asFlag)
|
||||
}
|
||||
|
||||
func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, taskID string) (wikiMoveTaskStatus, bool, error) {
|
||||
lastStatus := wikiMoveTaskStatus{TaskID: taskID}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
// The move request itself already succeeded. Treat poll failures as transient
|
||||
// until every attempt fails, then return a resume hint instead of discarding
|
||||
// the task identifier.
|
||||
for attempt := 1; attempt <= wikiMovePollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-time.After(wikiMovePollInterval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := client.GetMoveTask(ctx, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status attempt %d/%d failed: %v\n", attempt, wikiMovePollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
nextCommand := wikiMoveTaskResultCommand(taskID, runtime.As())
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki move task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
taskID,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
|
||||
for _, item := range common.GetSlice(task, "move_result") {
|
||||
resultMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var node *wikiNodeRecord
|
||||
if nodeMap := common.GetMap(resultMap, "node"); nodeMap != nil {
|
||||
parsedNode, err := parseWikiNodeRecord(nodeMap)
|
||||
if err != nil {
|
||||
return wikiMoveTaskStatus{}, err
|
||||
}
|
||||
node = parsedNode
|
||||
}
|
||||
|
||||
status.MoveResults = append(status.MoveResults, wikiMoveTaskResult{
|
||||
Node: node,
|
||||
Status: int(common.GetFloat(resultMap, "status")),
|
||||
StatusMsg: common.GetString(resultMap, "status_msg"),
|
||||
})
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func appendWikiNodeOutput(out map[string]interface{}, node *wikiNodeRecord) {
|
||||
if out == nil || node == nil {
|
||||
return
|
||||
}
|
||||
out["space_id"] = node.SpaceID
|
||||
out["node_token"] = node.NodeToken
|
||||
out["obj_token"] = node.ObjToken
|
||||
out["obj_type"] = node.ObjType
|
||||
out["parent_node_token"] = node.ParentNodeToken
|
||||
out["node_type"] = node.NodeType
|
||||
out["origin_node_token"] = node.OriginNodeToken
|
||||
out["title"] = node.Title
|
||||
out["has_child"] = node.HasChild
|
||||
}
|
||||
905
shortcuts/wiki/wiki_move_test.go
Normal file
905
shortcuts/wiki/wiki_move_test.go
Normal file
@@ -0,0 +1,905 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeWikiMoveNodeCall struct {
|
||||
SourceSpaceID string
|
||||
Spec wikiMoveSpec
|
||||
}
|
||||
|
||||
type fakeWikiDocsToWikiMoveCall struct {
|
||||
TargetSpaceID string
|
||||
Spec wikiMoveSpec
|
||||
}
|
||||
|
||||
type fakeWikiMoveClient struct {
|
||||
nodes map[string]*wikiNodeRecord
|
||||
|
||||
getNodeErr error
|
||||
moveNode *wikiNodeRecord
|
||||
moveNodeErr error
|
||||
docsResp *wikiMoveDocsResponse
|
||||
docsErr error
|
||||
|
||||
taskStatuses []wikiMoveTaskStatus
|
||||
taskErrs []error
|
||||
|
||||
getNodeCalls []string
|
||||
moveNodeCalls []fakeWikiMoveNodeCall
|
||||
docsToWikiCalls []fakeWikiDocsToWikiMoveCall
|
||||
moveTaskCallArgs []string
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
fake.getNodeCalls = append(fake.getNodeCalls, token)
|
||||
if fake.getNodeErr != nil {
|
||||
return nil, fake.getNodeErr
|
||||
}
|
||||
if node, ok := fake.nodes[token]; ok {
|
||||
return node, nil
|
||||
}
|
||||
return &wikiNodeRecord{}, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
|
||||
fake.moveNodeCalls = append(fake.moveNodeCalls, fakeWikiMoveNodeCall{SourceSpaceID: sourceSpaceID, Spec: spec})
|
||||
if fake.moveNodeErr != nil {
|
||||
return nil, fake.moveNodeErr
|
||||
}
|
||||
if fake.moveNode != nil {
|
||||
return fake.moveNode, nil
|
||||
}
|
||||
return &wikiNodeRecord{SpaceID: sourceSpaceID, NodeToken: spec.NodeToken}, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
|
||||
fake.docsToWikiCalls = append(fake.docsToWikiCalls, fakeWikiDocsToWikiMoveCall{TargetSpaceID: targetSpaceID, Spec: spec})
|
||||
if fake.docsErr != nil {
|
||||
return nil, fake.docsErr
|
||||
}
|
||||
if fake.docsResp != nil {
|
||||
return fake.docsResp, nil
|
||||
}
|
||||
return &wikiMoveDocsResponse{}, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiMoveClient) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
|
||||
idx := len(fake.moveTaskCallArgs)
|
||||
fake.moveTaskCallArgs = append(fake.moveTaskCallArgs, taskID)
|
||||
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
|
||||
return wikiMoveTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
|
||||
}
|
||||
if idx < len(fake.taskStatuses) {
|
||||
status := fake.taskStatuses[idx]
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
return wikiMoveTaskStatus{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
type mockWikiMoveTokenResolver struct {
|
||||
token string
|
||||
scopes string
|
||||
err error
|
||||
}
|
||||
|
||||
type wikiMoveAccountResolver struct {
|
||||
cfg *core.CliConfig
|
||||
}
|
||||
|
||||
func (r *wikiMoveAccountResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
return credential.AccountFromCliConfig(r.cfg), nil
|
||||
}
|
||||
|
||||
func (m *mockWikiMoveTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
token := m.token
|
||||
if token == "" {
|
||||
token = "test-token"
|
||||
}
|
||||
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
|
||||
}
|
||||
|
||||
var wikiMovePollMu sync.Mutex
|
||||
|
||||
func withSingleWikiMovePoll(t *testing.T) {
|
||||
t.Helper()
|
||||
wikiMovePollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := wikiMovePollAttempts, wikiMovePollInterval
|
||||
wikiMovePollAttempts, wikiMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
wikiMovePollAttempts, wikiMovePollInterval = prevAttempts, prevInterval
|
||||
wikiMovePollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) (*common.RuntimeContext, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime, stderr
|
||||
}
|
||||
|
||||
func decodeWikiEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal wiki envelope: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true envelope, got stdout=%s", stdout.String())
|
||||
}
|
||||
return env.Data
|
||||
}
|
||||
|
||||
func decodeWikiCapturedJSONBody(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("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec wikiMoveSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "node move rejects docs flags",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "node move requires target",
|
||||
spec: wikiMoveSpec{NodeToken: "wik_node"},
|
||||
wantErr: "cannot both be empty",
|
||||
},
|
||||
{
|
||||
name: "source space requires node token",
|
||||
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "can only be used with --node-token",
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires obj type",
|
||||
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-type is required",
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires obj token",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
|
||||
wantErr: "--obj-token is required",
|
||||
},
|
||||
{
|
||||
name: "docs to wiki requires target space",
|
||||
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
|
||||
wantErr: "--target-space-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateWikiMoveSpec(tt.spec)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWikiMoveSpecAcceptsValidModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, spec := range []wikiMoveSpec{
|
||||
{NodeToken: "wik_node", TargetSpaceID: "space_dst"},
|
||||
{ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst", TargetParentToken: "wik_parent", Apply: true},
|
||||
} {
|
||||
if err := validateWikiMoveSpec(spec); err != nil {
|
||||
t.Fatalf("validateWikiMoveSpec(%+v) error = %v", spec, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveDeclaredScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"}
|
||||
if !reflect.DeepEqual(WikiMove.Scopes, want) {
|
||||
t.Fatalf("WikiMove.Scopes = %v, want %v", WikiMove.Scopes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveShortcutMissingDeclaredScope(t *testing.T) {
|
||||
cfg := wikiTestConfig()
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
factory.Credential = credential.NewCredentialProvider(nil, &wikiMoveAccountResolver{cfg: cfg}, &mockWikiMoveTokenResolver{scopes: "wiki:node:read"}, nil)
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--node-token", "wik_node",
|
||||
"--target-space-id", "space_dst",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing scope error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing required scope(s): wiki:node:move") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskStatusPendingAndFallbackLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pending := wikiMoveTaskStatus{}
|
||||
if !pending.Pending() || pending.PrimaryStatusLabel() != "processing" {
|
||||
t.Fatalf("pending status = %+v", pending)
|
||||
}
|
||||
|
||||
ready := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: 0}}}
|
||||
if !ready.Ready() || ready.PrimaryStatusLabel() != "success" {
|
||||
t.Fatalf("ready status = %+v", ready)
|
||||
}
|
||||
|
||||
failed := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: -1}}}
|
||||
if !failed.Failed() || failed.PrimaryStatusLabel() != "failure" {
|
||||
t.Fatalf("failed status = %+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
MoveResults: []wikiMoveTaskResult{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: -3, StatusMsg: "permission denied"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != -3 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "permission denied" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
|
||||
}
|
||||
// FirstResult must keep its literal "first entry" semantics for callers
|
||||
// that flatten node fields from the first move_result.
|
||||
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
|
||||
t.Fatalf("FirstResult = %+v, want first success entry", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
MoveResults: []wikiMoveTaskResult{
|
||||
{Status: 0, StatusMsg: "success"},
|
||||
{Status: 1, StatusMsg: "processing"},
|
||||
},
|
||||
}
|
||||
if got := status.PrimaryStatusCode(); got != 1 {
|
||||
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
|
||||
}
|
||||
if got := status.PrimaryStatusLabel(); got != "processing" {
|
||||
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveValidateRejectsBotMyLibrary(t *testing.T) {
|
||||
cfg := wikiTestConfig()
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--obj-type", "docx",
|
||||
"--obj-token", "doccnXXX",
|
||||
"--target-space-id", "my_library",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for bot + my_library, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "my_library") || !strings.Contains(err.Error(), "--as bot") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveValidateAllowsUserMyLibrary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Bot guard must not affect user identity. We only assert the my_library
|
||||
// validation path doesn't trip; an empty obj-token still fails downstream
|
||||
// for unrelated reasons, so we check the error does not mention my_library.
|
||||
if err := validateWikiMoveSpec(wikiMoveSpec{
|
||||
ObjType: "docx",
|
||||
ObjToken: "doccnXXX",
|
||||
TargetSpaceID: "my_library",
|
||||
}); err != nil {
|
||||
t.Fatalf("validateWikiMoveSpec(user my_library) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveDryRunNodeMoveIncludesResolutionSteps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +move"}
|
||||
cmd.Flags().String("node-token", "", "")
|
||||
cmd.Flags().String("source-space-id", "", "")
|
||||
cmd.Flags().String("target-space-id", "", "")
|
||||
cmd.Flags().String("target-parent-token", "", "")
|
||||
cmd.Flags().String("obj-type", "", "")
|
||||
cmd.Flags().String("obj-token", "", "")
|
||||
cmd.Flags().Bool("apply", false, "")
|
||||
if err := cmd.Flags().Set("node-token", "wik_node"); err != nil {
|
||||
t.Fatalf("set --node-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-parent-token", "wik_parent"); err != nil {
|
||||
t.Fatalf("set --target-parent-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := WikiMove.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"description":"3-step orchestration:`)) {
|
||||
t.Fatalf("dry run missing 3-step description: %s", string(data))
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"target_parent_token":"wik_parent"`)) {
|
||||
t.Fatalf("dry run missing target_parent_token body: %s", string(data))
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`/open-apis/wiki/v2/spaces/\u003cresolved_source_space_id\u003e/nodes/wik_node/move`)) {
|
||||
t.Fatalf("dry run missing resolved source placeholder: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveDryRunDocsToWikiIncludesTaskPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +move"}
|
||||
cmd.Flags().String("node-token", "", "")
|
||||
cmd.Flags().String("source-space-id", "", "")
|
||||
cmd.Flags().String("target-space-id", "", "")
|
||||
cmd.Flags().String("target-parent-token", "", "")
|
||||
cmd.Flags().String("obj-type", "", "")
|
||||
cmd.Flags().String("obj-token", "", "")
|
||||
cmd.Flags().Bool("apply", false, "")
|
||||
if err := cmd.Flags().Set("obj-type", "sheet"); err != nil {
|
||||
t.Fatalf("set --obj-type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("obj-token", "sheet_token"); err != nil {
|
||||
t.Fatalf("set --obj-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-space-id", "space_dst"); err != nil {
|
||||
t.Fatalf("set --target-space-id: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("apply", "true"); err != nil {
|
||||
t.Fatalf("set --apply: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := WikiMove.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"obj_type":"sheet"`)) || !bytes.Contains(data, []byte(`"apply":true`)) {
|
||||
t.Fatalf("dry run missing docs-to-wiki body: %s", string(data))
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"task_type":"move"`)) {
|
||||
t.Fatalf("dry run missing task polling params: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiNodeMoveSpacesUsesSourceAndTargetLookups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiMoveClient{
|
||||
nodes: map[string]*wikiNodeRecord{
|
||||
"wik_node": {SpaceID: "space_src"},
|
||||
"wik_parent": {SpaceID: "space_dst"},
|
||||
},
|
||||
}
|
||||
|
||||
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
TargetParentToken: "wik_parent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveWikiNodeMoveSpaces() error = %v", err)
|
||||
}
|
||||
if sourceSpaceID != "space_src" || targetSpaceID != "space_dst" {
|
||||
t.Fatalf("resolved spaces = (%q, %q), want (%q, %q)", sourceSpaceID, targetSpaceID, "space_src", "space_dst")
|
||||
}
|
||||
if strings.Join(client.getNodeCalls, ",") != "wik_node,wik_parent" {
|
||||
t.Fatalf("getNodeCalls = %v, want source and target-parent lookups", client.getNodeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiNodeMoveSpacesRejectsTargetSpaceMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiMoveClient{
|
||||
nodes: map[string]*wikiNodeRecord{
|
||||
"wik_parent": {SpaceID: "space_parent"},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
SourceSpaceID: "space_src",
|
||||
TargetSpaceID: "space_other",
|
||||
TargetParentToken: "wik_parent",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("expected mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeMoveReturnsResolvedMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeWikiMoveClient{
|
||||
nodes: map[string]*wikiNodeRecord{
|
||||
"wik_node": {SpaceID: "space_src"},
|
||||
"wik_parent": {SpaceID: "space_dst"},
|
||||
},
|
||||
moveNode: &wikiNodeRecord{
|
||||
SpaceID: "space_dst",
|
||||
NodeToken: "wik_moved",
|
||||
ObjToken: "sheet_token",
|
||||
ObjType: "sheet",
|
||||
ParentNodeToken: "wik_parent",
|
||||
NodeType: wikiNodeTypeOrigin,
|
||||
Title: "Roadmap",
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeMove(context.Background(), client, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
TargetParentToken: "wik_parent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeMove() error = %v", err)
|
||||
}
|
||||
if len(client.moveNodeCalls) != 1 {
|
||||
t.Fatalf("MoveNode called %d times, want 1", len(client.moveNodeCalls))
|
||||
}
|
||||
if client.moveNodeCalls[0].SourceSpaceID != "space_src" {
|
||||
t.Fatalf("source space = %q, want %q", client.moveNodeCalls[0].SourceSpaceID, "space_src")
|
||||
}
|
||||
if out["mode"] != wikiMoveModeNode || out["source_space_id"] != "space_src" || out["target_space_id"] != "space_dst" {
|
||||
t.Fatalf("unexpected node move output: %#v", out)
|
||||
}
|
||||
if out["node_token"] != "wik_moved" || out["title"] != "Roadmap" {
|
||||
t.Fatalf("node fields not propagated: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiMoveDispatchesByMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
|
||||
moveNode: &wikiNodeRecord{SpaceID: "space_dst", NodeToken: "wik_node"},
|
||||
}
|
||||
|
||||
nodeOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
NodeToken: "wik_node",
|
||||
SourceSpaceID: "space_src",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiMove(node) error = %v", err)
|
||||
}
|
||||
if nodeOut["mode"] != wikiMoveModeNode {
|
||||
t.Fatalf("node mode output = %#v", nodeOut)
|
||||
}
|
||||
|
||||
docsOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiMove(docs_to_wiki) error = %v", err)
|
||||
}
|
||||
if docsOut["mode"] != wikiMoveModeDocsToWiki {
|
||||
t.Fatalf("docs-to-wiki output = %#v", docsOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveSyncReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("expected ready sync result, got %#v", out)
|
||||
}
|
||||
if out["wiki_token"] != "wik_ready" || out["node_token"] != "wik_ready" {
|
||||
t.Fatalf("wiki token fields = %#v", out)
|
||||
}
|
||||
if len(client.docsToWikiCalls) != 1 || client.docsToWikiCalls[0].TargetSpaceID != "space_dst" {
|
||||
t.Fatalf("unexpected docs-to-wiki calls: %#v", client.docsToWikiCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveApplied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{Applied: true},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["applied"] != true || out["ready"] != false || out["failed"] != false {
|
||||
t.Fatalf("expected applied response, got %#v", out)
|
||||
}
|
||||
if out["status_msg"] != "move request submitted for approval" {
|
||||
t.Fatalf("status_msg = %#v", out["status_msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveAsyncReady(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
|
||||
taskStatuses: []wikiMoveTaskStatus{{
|
||||
MoveResults: []wikiMoveTaskResult{{
|
||||
Status: 0,
|
||||
StatusMsg: "success",
|
||||
Node: &wikiNodeRecord{
|
||||
SpaceID: "space_dst",
|
||||
NodeToken: "wik_done",
|
||||
ObjToken: "sheet_token",
|
||||
ObjType: "sheet",
|
||||
NodeType: wikiNodeTypeOrigin,
|
||||
Title: "Roadmap",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["task_id"] != "task_123" || out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("unexpected async-ready output: %#v", out)
|
||||
}
|
||||
if out["wiki_token"] != "wik_done" || out["title"] != "Roadmap" || out["status_msg"] != "success" {
|
||||
t.Fatalf("async-ready output missing flattened fields: %#v", out)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Docs-to-wiki move is async") || !strings.Contains(stderr.String(), "completed successfully") {
|
||||
t.Fatalf("stderr = %q, want async progress logs", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveAsyncTimeoutReturnsNextCommand(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
|
||||
taskStatuses: []wikiMoveTaskStatus{{
|
||||
MoveResults: []wikiMoveTaskResult{{Status: 1, StatusMsg: "processing"}},
|
||||
}},
|
||||
}
|
||||
|
||||
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
|
||||
}
|
||||
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wikiMoveTaskResultCommand("task_123", core.AsUser) {
|
||||
t.Fatalf("expected timeout response, got %#v", out)
|
||||
}
|
||||
if out["status_msg"] != "processing" {
|
||||
t.Fatalf("status_msg = %#v, want processing", out["status_msg"])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Continue with") {
|
||||
t.Fatalf("stderr = %q, want continuation hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiDocsToWikiMoveAsyncFailureReturnsStructuredError(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
|
||||
taskStatuses: []wikiMoveTaskStatus{{
|
||||
MoveResults: []wikiMoveTaskResult{{Status: -1, StatusMsg: "approval rejected"}},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
|
||||
ObjType: "sheet",
|
||||
ObjToken: "sheet_token",
|
||||
TargetSpaceID: "space_dst",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki move task failed: approval rejected") {
|
||||
t.Fatalf("expected async failure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveExecuteNodeShortcut(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"space_id": "space_src"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"space_id": "space_dst"},
|
||||
},
|
||||
},
|
||||
})
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_node/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_moved",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"parent_node_token": "wik_parent",
|
||||
"node_type": "origin",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(moveStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--node-token", "wik_node",
|
||||
"--target-parent-token", "wik_parent",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["mode"] != wikiMoveModeNode || data["source_space_id"] != "space_src" || data["target_space_id"] != "space_dst" {
|
||||
t.Fatalf("unexpected node shortcut output: %#v", data)
|
||||
}
|
||||
body := decodeWikiCapturedJSONBody(t, moveStub)
|
||||
if body["target_parent_token"] != "wik_parent" {
|
||||
t.Fatalf("move body = %#v, want target_parent_token", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveExecuteDocsToWikiShortcutAsyncSuccess(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
docsStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_dst/nodes/move_docs_to_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_123",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(docsStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"task_id": "task_123",
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"node_type": "origin",
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiMove, []string{
|
||||
"+move",
|
||||
"--obj-type", "sheet",
|
||||
"--obj-token", "sheet_token",
|
||||
"--target-space-id", "space_dst",
|
||||
"--apply",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["mode"] != wikiMoveModeDocsToWiki || data["ready"] != true || data["wiki_token"] != "wik_done" {
|
||||
t.Fatalf("unexpected docs-to-wiki shortcut output: %#v", data)
|
||||
}
|
||||
body := decodeWikiCapturedJSONBody(t, docsStub)
|
||||
if body["obj_type"] != "sheet" || body["obj_token"] != "sheet_token" || body["apply"] != true {
|
||||
t.Fatalf("docs-to-wiki body = %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
|
||||
withSingleWikiMovePoll(t)
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
|
||||
if err == nil {
|
||||
t.Fatal("expected pollWikiMoveTask() error, got nil")
|
||||
}
|
||||
if ready {
|
||||
t.Fatal("expected ready=false when every poll fails")
|
||||
}
|
||||
if status.TaskID != "task_123" {
|
||||
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskStatusFallbackTaskIDAndNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status, err := parseWikiMoveTaskStatus("task_fallback", map[string]interface{}{
|
||||
"move_result": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_dst",
|
||||
"node_token": "wik_done",
|
||||
"obj_token": "sheet_token",
|
||||
"obj_type": "sheet",
|
||||
"node_type": wikiNodeTypeOrigin,
|
||||
"title": "Roadmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiMoveTaskStatus() error = %v", err)
|
||||
}
|
||||
if status.TaskID != "task_fallback" {
|
||||
t.Fatalf("TaskID = %q, want %q", status.TaskID, "task_fallback")
|
||||
}
|
||||
if !status.Ready() || status.PrimaryStatusLabel() != "success" {
|
||||
t.Fatalf("unexpected parsed status: %+v", status)
|
||||
}
|
||||
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node.NodeToken != "wik_done" {
|
||||
t.Fatalf("parsed node = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiMoveTaskStatusRejectsMissingTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiMoveTaskStatus("task_123", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing task") {
|
||||
t.Fatalf("expected missing task error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -94,15 +94,18 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func TestWikiShortcutsIncludesNodeCreate(t *testing.T) {
|
||||
func TestWikiShortcutsIncludeMoveAndNodeCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 1 {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want 1", len(shortcuts))
|
||||
if len(shortcuts) != 2 {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want 2", len(shortcuts))
|
||||
}
|
||||
if shortcuts[0].Command != "+node-create" {
|
||||
t.Fatalf("shortcut command = %q, want %q", shortcuts[0].Command, "+node-create")
|
||||
if shortcuts[0].Command != "+move" {
|
||||
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")
|
||||
}
|
||||
if shortcuts[1].Command != "+node-create" {
|
||||
t.Fatalf("shortcuts[1].Command = %q, want %q", shortcuts[1].Command, "+node-create")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 修改标题
|
||||
- 使用 `drive files patch` 命令,通过new_title字段可以修改标题,支持 docx、sheet、bitable、file、wiki、folder 类型
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 文档类型与 Token
|
||||
|
||||
@@ -126,4 +126,40 @@ lark-cli sheets spreadsheet.sheet.filters update \
|
||||
|
||||
**常见错误:**
|
||||
- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create
|
||||
- `Excess Limit`:update 时重复添加同一列条件
|
||||
- `Excess Limit`:update 时重复添加同一列条件
|
||||
|
||||
### 单元格数据类型
|
||||
|
||||
接受二维数组的 shortcut(`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。
|
||||
|
||||
| 类型 | 写入格式 | 示例 |
|
||||
|------|---------|------|
|
||||
| 字符串 | `"文本"` | `"hello"` |
|
||||
| 数字 | `数字` | `123`、`3.14` |
|
||||
| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` |
|
||||
| 链接(纯 URL) | `"URL 字符串"` | `"https://example.com"` |
|
||||
| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` |
|
||||
| 邮箱 | `"邮箱字符串"` | `"user@example.com"` |
|
||||
| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` |
|
||||
| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`(notify 可选,默认 false;仅在用户明确要求通知时设为 true) |
|
||||
| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` |
|
||||
| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` |
|
||||
|
||||
**写入公式示例**:
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用对象格式
|
||||
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]'
|
||||
|
||||
# ❌ 错误:直接传字符串,会被存为纯文本
|
||||
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[["=SUM(C2:C5)"]]'
|
||||
```
|
||||
|
||||
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
|
||||
|
||||
**限制**:
|
||||
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号
|
||||
|
||||
@@ -31,7 +31,7 @@ lark-cli base +record-upload-attachment \
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--record-id <id>` | 是 | 记录 ID |
|
||||
| `--field-id <id_or_name>` | 是 | 附件字段 ID 或字段名 |
|
||||
| `--file <path>` | 是 | 本地文件路径,最大 20MB |
|
||||
| `--file <path>` | 是 | 本地文件路径,最大 2GB |
|
||||
| `--name <name>` | 否 | 写入附件字段时显示的文件名,默认使用本地文件名 |
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ lark-cli base +record-upload-attachment \
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 目标字段必须是 `attachment` 字段。
|
||||
- ⚠️ 记录里的附件 `file_token` 属于 Drive media token;下载时不要走 `lark-cli drive +download`,应使用 `lark-cli docs +media-download --token <file_token> --output <path>`。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" b
|
||||
|
||||
- 使用标准 Markdown 语法作为基础
|
||||
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
|
||||
- 需要显示特殊字符时使用反斜杠转义:`* ~ ` $ [ ] < > { } | ^`
|
||||
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
|
||||
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3`、`version~1.0`、`final_trajectory` 通常应保持原样,只有像 `*斜体*`、`**粗体**`、`~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
|
||||
|
||||
---
|
||||
|
||||
@@ -657,7 +658,7 @@ $$
|
||||
## 最佳实践
|
||||
|
||||
- **空行分隔**:不同块类型之间用空行分隔
|
||||
- **转义字符**:特殊字符用 `\` 转义:`\*` `\~` `\``
|
||||
- **转义字符**:只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3`、`version~1.0`、`final_trajectory` 这类普通文本通常不需要转义
|
||||
- **图片**:使用 URL,系统自动下载上传
|
||||
- **分栏**:列宽总和必须为 100
|
||||
- **表格选择**:简单数据用 Markdown,复杂嵌套用 `<lark-table>`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间:管理云空间中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件;也负责把本地 Word/Markdown/Excel/CSV 导入为飞书在线云文档(docx、sheet、bitable)。当用户需要上传或下载文件、整理云空间目录、查看文件详情、管理评论、管理文档权限、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。"
|
||||
description: "飞书云空间:管理云空间中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV 导入为飞书在线云文档(docx、sheet、bitable)。当用户需要上传或下载文件、整理云空间目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -21,6 +21,9 @@ metadata:
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 修改标题
|
||||
- 使用 `drive files patch` 命令,通过new_title字段可以修改标题,支持 docx、sheet、bitable、file、wiki、folder 类型
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 文档类型与 Token
|
||||
@@ -156,7 +159,7 @@ Drive Folder (云空间文件夹)
|
||||
- 使用 `drive file.comments batch_query` 是**已知评论 ID 后**的批量查询,需要传入具体的评论 ID 列表。
|
||||
- 使用 `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论,或获取"最新/最后 N 条评论"等场景。
|
||||
|
||||
#### Reaction 场景
|
||||
#### Reaction / 表情场景
|
||||
- 遇到评论 / 回复上的 reaction(表情、各表情数量、谁点了什么、添加/删除表情)相关问题时,**先阅读 [lark-drive-reactions.md](../../skills/lark-drive/references/lark-drive-reactions.md) 了解如何使用**。
|
||||
|
||||
### 典型错误与解决方案
|
||||
@@ -194,6 +197,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
|----------|------|
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
@@ -216,6 +220,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
- `copy` — 复制文件
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单
|
||||
- `patch` — 修改文件标题
|
||||
|
||||
### file.comments
|
||||
|
||||
@@ -266,6 +271,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
|
||||
103
skills/lark-drive/references/lark-drive-create-shortcut.md
Normal file
103
skills/lark-drive/references/lark-drive-create-shortcut.md
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
# drive +create-shortcut
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
在目标文件夹中为一个现有 Drive 文件创建快捷方式。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 为普通文件创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file
|
||||
|
||||
# 为新版文档创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx
|
||||
|
||||
# 为电子表格创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <SHEET_TOKEN> \
|
||||
--type sheet
|
||||
|
||||
# 仅预览即将发起的请求,不真正执行
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 是 | 目标父文件夹 token |
|
||||
| `--file-token` | 是 | 源文件 token,表示被引用的原始文件 |
|
||||
| `--type` | 是 | 源文件类型,推荐值:`file`、`docx`、`doc`、`sheet`、`bitable`、`mindnote`、`slides` |
|
||||
|
||||
## 输入规则
|
||||
|
||||
- 该 shortcut 的最小输入是 `--folder-token` + `--file-token` + `--type`
|
||||
- CLI 层会把 `--file-token` 和 `--type` 组装为底层 API 所需的 `refer_entity`
|
||||
- `--file-token` 必须是 Drive 文件 token,不要直接传 wiki 节点 token
|
||||
- 如果来源是 `/wiki/...` 链接,必须先按 [`lark-drive`](../SKILL.md) 中的 wiki 解析流程拿到真实 `obj_token`,再创建快捷方式
|
||||
- 目标位置必须是云空间文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
|
||||
|
||||
## 类型说明
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `file` | 普通文件 |
|
||||
| `docx` | 新版云文档 |
|
||||
| `doc` | 旧版云文档 |
|
||||
| `sheet` | 电子表格 |
|
||||
| `bitable` | 多维表格 |
|
||||
| `mindnote` | 思维笔记 |
|
||||
| `slides` | 幻灯片 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 成功时会调用 `POST /open-apis/drive/v1/files/create_shortcut`
|
||||
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto`、`--format`、`--jq`、`--dry-run` 使用
|
||||
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建快捷方式
|
||||
- 这是写入操作;执行前应确认目标文件夹和源文件都准确无误
|
||||
|
||||
## 限制
|
||||
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS,且 10000 次/天
|
||||
- 不支持跨租户、跨地域创建快捷方式
|
||||
- 不支持跨品牌创建快捷方式
|
||||
- 如果目标父文件夹单层挂载数量超过限制,会返回 `1062507`
|
||||
|
||||
## 权限要求
|
||||
|
||||
- 当前调用身份需要能访问源文件
|
||||
- 当前调用身份需要对目标文件夹有编辑权限
|
||||
- 如果权限不足,常见表现为 `1061004 forbidden`
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误码 / 错误信息 | 原因 | 处理建议 |
|
||||
|------|------|------|
|
||||
| `1061002 params error` | 缺少必填参数,或 `--file-token` / `--type` 组合无法构成有效源文件信息 | 检查 `--file-token`、`--type` 是否完整且匹配;如显式传了 `--folder-token`,再确认其值有效 |
|
||||
| `1061003 not found` | 源文件或目标文件夹不存在 | 重新确认 token 是否正确、资源是否已删除 |
|
||||
| `1061004 forbidden` | 对源文件没有访问权限,或对目标文件夹没有编辑权限 | 切换到有权限的身份,或先授予文档 / 文件夹权限 |
|
||||
| `1061005 auth failed` | 身份类型或 access token 不正确 | 检查 `--as` 使用的身份及当前登录态 |
|
||||
| `1061007 file has been delete` | 源文件已删除 | 确认原文件仍存在,再重新执行 |
|
||||
| `1062507 parent node out of sibling num` | 目标文件夹单层挂载数超过上限 | 清理目标目录,或换一个父文件夹 |
|
||||
| `1061045 resource contention occurred, please retry` | 平台内部资源争抢 | 稍后重试,不要并发重复调用 |
|
||||
| `1064510 cross tenant and unit not support` | 跨租户或跨地域请求 | 改为在同租户、同地域范围内操作 |
|
||||
| `1064511 cross brand not support` | 跨品牌请求 | 改为在同品牌环境内操作 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -5,6 +5,33 @@
|
||||
|
||||
将文件或文件夹移动到用户云空间的其他位置。
|
||||
|
||||
## 与 `wiki +move` 的区别
|
||||
|
||||
- `drive +move` 只处理 **Drive 文件夹树内部** 的位置调整,目标位置用 `--folder-token` 表示
|
||||
- `wiki +move` 处理的是 **Wiki 知识空间 / 页面层级**:要么移动已有 Wiki 节点,要么把 Drive 文档迁入 Wiki
|
||||
- 如果用户说“移动到某个文件夹”“移动到我的空间根目录”,应使用 `drive +move`
|
||||
- 如果用户说“移动到某个知识库 / 页面下”“迁入 Wiki / 知识空间”,应使用 `wiki +move`
|
||||
- 如果用户说“移动到我的文档库 / 我的知识库 / 个人知识库 / my_library”,不要使用 `drive +move`;先按 Wiki 目标处理
|
||||
- `我的文档库` 不是 Drive root folder,也不是 `--folder-token` 省略后的默认目的地
|
||||
- `drive +move` 不支持 wiki 文档;如果目标是 Wiki,不要尝试用 `drive +move` 代替
|
||||
|
||||
## 不要误用到 `我的文档库`
|
||||
|
||||
下面几种说法都**不应该**触发 `drive +move`:
|
||||
|
||||
- `移动到我的文档库`
|
||||
- `放到我的知识库`
|
||||
- `迁入个人知识库`
|
||||
- `move to My Document Library`
|
||||
|
||||
这些目标都应该先走 Wiki 解析流程:
|
||||
|
||||
```bash
|
||||
lark-cli wiki spaces get --params '{"space_id":"my_library"}'
|
||||
```
|
||||
|
||||
拿到真实 `space_id` 后,再改用 `wiki +move`。不要因为 `drive +move` 可以省略 `--folder-token` 就把它当作“我的文档库”的近似目标。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -60,6 +87,7 @@ lark-cli drive +move \
|
||||
- **轮询超时不是失败**:文件夹移动内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id`、`status`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
|
||||
- **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间")
|
||||
- **不要混淆产品概念**:这里的“根文件夹 / 我的空间”仅属于 Drive 文件夹树,不等于 Wiki 的“我的文档库”
|
||||
- **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。
|
||||
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹、Wiki 节点 / 文档迁入 Wiki 等多种异步任务的结果查询,统一接口方便调用。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 对于 `import` 场景,如果使用 `--as bot` 且这次查询**已经拿到最终在线文档目标**(`ready=true` 且返回了最终 `token` / `url`),CLI 会**再次尝试为当前 CLI 用户自动授予该资源的 `full_access`(可管理权限)**。
|
||||
@@ -35,15 +35,20 @@ lark-cli drive +task_result \
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
|
||||
# 查询 Wiki 移动任务结果(wiki +move 异步超时后的续跑)
|
||||
lark-cli drive +task_result \
|
||||
--scenario wiki_move \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务) |
|
||||
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务)、`wiki_move` (Wiki 移动任务) |
|
||||
| `--ticket` | 条件必填 | 异步任务 ticket,**import/export 场景必填** |
|
||||
| `--task-id` | 条件必填 | 异步任务 ID,**task_check 场景必填** |
|
||||
| `--task-id` | 条件必填 | 异步任务 ID,**task_check / wiki_move 场景必填** |
|
||||
| `--file-token` | 条件必填 | 导出任务对应的源文档 token,**export 场景必填** |
|
||||
|
||||
## 场景说明
|
||||
@@ -53,6 +58,7 @@ lark-cli drive +task_result \
|
||||
| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` |
|
||||
| `export` | 文档导出任务(如云文档导出为 PDF/Word) | `--ticket`、`--file-token` |
|
||||
| `task_check` | 文件夹移动/删除任务 | `--task-id` |
|
||||
| `wiki_move` | Wiki 移动任务(`wiki +move` 的 docs-to-wiki 异步流程,超时后续跑用) | `--task-id` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
@@ -135,6 +141,55 @@ lark-cli drive +task_result \
|
||||
- `ready`: 是否已经完成
|
||||
- `failed`: 是否已经失败
|
||||
|
||||
### Wiki_move 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "wiki_move",
|
||||
"task_id": "<TASK_ID>",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"wiki_token": "wikcnXXX",
|
||||
"node_token": "wikcnXXX",
|
||||
"space_id": "<TARGET_SPACE_ID>",
|
||||
"obj_token": "<OBJ_TOKEN>",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "",
|
||||
"node_type": "origin",
|
||||
"origin_node_token": "",
|
||||
"title": "项目计划",
|
||||
"has_child": false,
|
||||
"node": {
|
||||
"space_id": "<TARGET_SPACE_ID>",
|
||||
"node_token": "wikcnXXX",
|
||||
"obj_token": "<OBJ_TOKEN>",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "",
|
||||
"node_type": "origin",
|
||||
"origin_node_token": "",
|
||||
"title": "项目计划",
|
||||
"has_child": false
|
||||
},
|
||||
"move_results": [
|
||||
{
|
||||
"status": 0,
|
||||
"status_msg": "success",
|
||||
"node": { "...": "同上" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `ready`: 所有 `move_results[].status` 都为 `0` 时为 `true`
|
||||
- `failed`: 任一 `move_results[].status` 小于 `0` 时为 `true`
|
||||
- `status` / `status_msg`: 第一个 move_result 的状态码 / 标签(无结果时回退为 `1` / `processing`)
|
||||
- `wiki_token` / `node_token`: 移入 Wiki 后的目标节点 token(首个结果有 `node.node_token` 时镜像到顶层,便于下游脚本使用)
|
||||
- `space_id`、`obj_token`、`obj_type`、`title` 等:从首个 `move_results[0].node` 平铺到顶层,方便直接引用
|
||||
- `move_results`: 保留完整列表(适用于一次任务移动多个文档的场景)
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 配合 +import 使用
|
||||
@@ -162,6 +217,20 @@ lark-cli drive +move --file-token <FOLDER_TOKEN> --type folder --folder-token <T
|
||||
lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>
|
||||
```
|
||||
|
||||
### 配合 wiki +move 使用
|
||||
|
||||
```bash
|
||||
# 1. 把 Drive 文档迁入 Wiki(异步任务可能返回 task_id)
|
||||
lark-cli wiki +move --obj-type docx --obj-token <DOC_TOKEN> --target-space-id <TARGET_SPACE_ID>
|
||||
# 若内置轮询窗口内完成:直接返回 ready=true 和 wiki_token
|
||||
# 若轮询窗口结束仍未完成:返回 ready=false、task_id、timed_out=true 和 next_command
|
||||
|
||||
# 2. 续跑查询 Wiki 移动结果(next_command 即下面这条)
|
||||
lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID> --as user
|
||||
```
|
||||
|
||||
> **身份保持一致**:续跑命令的 `--as` 必须与原 `wiki +move` 调用一致;`wiki +move` 的 `next_command` 已自动带上正确的 `--as`。
|
||||
|
||||
### 配合 +export 使用
|
||||
|
||||
```bash
|
||||
@@ -184,6 +253,7 @@ lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
|
||||
| import | `drive:drive.metadata:readonly` |
|
||||
| export | `drive:drive.metadata:readonly` |
|
||||
| task_check | `drive:drive.metadata:readonly` |
|
||||
| wiki_move | `wiki:space:read` |
|
||||
|
||||
> [!NOTE]
|
||||
> `import` 场景在 `--as bot` 且任务最终就绪时,还可能额外尝试一次协作者授权;如果 `permission_grant.status = failed`,请根据失败信息检查应用是否具备相应的文档协作者授权能力。
|
||||
|
||||
@@ -17,7 +17,7 @@ Subscribe to Lark events via WebSocket long connection, outputting NDJSON to std
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Subscribe to all registered events (catch-all mode, 24 common event types)
|
||||
# Subscribe to all registered events (catch-all mode, 25 common event types)
|
||||
lark-cli event +subscribe
|
||||
|
||||
# Subscribe to specific event types only
|
||||
@@ -153,6 +153,7 @@ The following 24 event types are registered in catch-all mode (when `--event-typ
|
||||
| Event Type | Description | Required Scope |
|
||||
|-----------|-------------|---------------|
|
||||
| `task.task.update_tenant_v1` | Task updated (tenant) | `task:task:readonly` |
|
||||
| `task.task.update_user_access_v2` | Task updated (user access) | `task:task:readonly` |
|
||||
| `task.task.comment_updated_v1` | Task comment updated | `task:task:readonly` |
|
||||
|
||||
### Drive
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-im
|
||||
version: 1.0.0
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -62,7 +62,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; downloads image/file resources by message-id and file-key to a safe relative output path |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
|
||||
@@ -36,7 +36,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--chat-id <id>` | One of two | Specify the conversation by its chat_id directly (e.g., group chat `oc_xxx`) |
|
||||
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically |
|
||||
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity |
|
||||
| `--start <time>` | No | Start time (ISO 8601 or date only) |
|
||||
| `--end <time>` | No | End time (ISO 8601 or date only) |
|
||||
| `--sort <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
@@ -116,6 +116,7 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
|---------|---------|---------|
|
||||
| `specify --chat-id <chat_id> or --user-id <open_id>` | Neither `--chat-id` nor `--user-id` was provided | You must provide exactly one |
|
||||
| `--chat-id and --user-id cannot be specified together` | Both parameters were provided | Use only one |
|
||||
| `--user-id requires user identity (--as user); use --chat-id when calling with bot identity` | `--user-id` was used with bot identity | The p2p resolution endpoint requires user identity. Either pass `--as user` or look up the p2p `chat_id` separately and pass it via `--chat-id` |
|
||||
| `P2P chat not found for this user` | `--user-id` was used but no p2p chat exists for the current identity and that user | Confirm the target direct-message relationship exists for the current identity |
|
||||
| `--start: invalid time format` | Invalid time format | Use ISO 8601 or date-only format such as `2026-03-10` |
|
||||
| Permission denied | Message read permissions are missing | Ensure the app has `im:message:readonly` and `im:chat:read` enabled |
|
||||
@@ -130,7 +131,7 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
```
|
||||
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
|
||||
2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls.
|
||||
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually.
|
||||
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`.
|
||||
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.
|
||||
5. **For full content:** table output truncates content. Use `--format json` when you need the complete message body.
|
||||
6. **For sender info:** the command already resolves sender names, so you do not need a separate lookup.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Download image or file resources from a message. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
|
||||
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
|
||||
|
||||
> **Note:** read-only message commands render resource keys in message content, but they do not download binaries automatically. Use this command whenever you need to fetch the actual image/file bytes or save them to a specific path.
|
||||
|
||||
@@ -34,10 +34,26 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
|
||||
| `--message-id <id>` | Yes | Message ID (`om_xxx` format) |
|
||||
| `--file-key <key>` | Yes | Resource key (`img_xxx` or `file_xxx`) |
|
||||
| `--type <type>` | Yes | Resource type: `image` or `file` |
|
||||
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name) |
|
||||
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name). File extension is automatically added based on Content-Type if not provided |
|
||||
| `--as <identity>` | No | Identity type: `user` (default) or `bot` |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
## Large File Download (Auto Chunking)
|
||||
|
||||
When downloading large files, the command automatically uses **HTTP Range requests** for reliable chunked downloading:
|
||||
|
||||
| Behavior | Details |
|
||||
|----------|---------|
|
||||
| Probe chunk | First 128 KB to detect file size and Content-Type |
|
||||
| Chunk size | 8 MB per subsequent request |
|
||||
| Workers | Single-threaded sequential download (ensures reliability) |
|
||||
| Retries | Up to 2 retries for transient request failures, with exponential backoff |
|
||||
|
||||
**Benefits:**
|
||||
- Reduces the impact of transient request failures during large downloads
|
||||
- Automatically detects and appends correct file extension from Content-Type
|
||||
- Validates file size integrity after download completion
|
||||
|
||||
## `file_key` Sources
|
||||
|
||||
Different resource markers in message content correspond to different `file_key` and `type` values:
|
||||
@@ -69,7 +85,8 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
|
||||
| Download failed | `file_key` does not match the `message_id` | Make sure the `file_key` came from that message's content |
|
||||
| Hit error code 234002 or 14005 | No permission, **not** missing API scope | no access to this chat or file was deleted — do not retry, return the error to the user |
|
||||
| Permission denied | `im:message:readonly` is not authorized | Run `auth login --scope "im:message:readonly"` |
|
||||
| File too large | Over the 100 MB limit | This is a Feishu API limitation and cannot be bypassed with this endpoint |
|
||||
| File size mismatch | Chunked download integrity check failed | Network instability during download; retry the command |
|
||||
| Content-Range error | Server returned invalid range header | Transient API issue; retry the command |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -141,6 +141,42 @@ lark-cli sheets spreadsheet.sheet.filters update \
|
||||
- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create
|
||||
- `Excess Limit`:update 时重复添加同一列条件
|
||||
|
||||
### 单元格数据类型
|
||||
|
||||
接受二维数组的 shortcut(`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。
|
||||
|
||||
| 类型 | 写入格式 | 示例 |
|
||||
|------|---------|------|
|
||||
| 字符串 | `"文本"` | `"hello"` |
|
||||
| 数字 | `数字` | `123`、`3.14` |
|
||||
| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` |
|
||||
| 链接(纯 URL) | `"URL 字符串"` | `"https://example.com"` |
|
||||
| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` |
|
||||
| 邮箱 | `"邮箱字符串"` | `"user@example.com"` |
|
||||
| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` |
|
||||
| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`(notify 可选,默认 false;仅在用户明确要求通知时设为 true) |
|
||||
| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` |
|
||||
| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` |
|
||||
|
||||
**写入公式示例**:
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用对象格式
|
||||
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]'
|
||||
|
||||
# ❌ 错误:直接传字符串,会被存为纯文本
|
||||
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[["=SUM(C2:C5)"]]'
|
||||
```
|
||||
|
||||
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
|
||||
|
||||
**限制**:
|
||||
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-set-dropdown.md`](references/lark-sheets-set-dropdown.md)。值中的字符串不能包含逗号
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
@@ -165,6 +201,25 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
|
||||
| [`+update-dimension`](references/lark-sheets-update-dimension.md) | Update row or column properties (visibility, size) |
|
||||
| [`+move-dimension`](references/lark-sheets-move-dimension.md) | Move rows or columns to a new position |
|
||||
| [`+delete-dimension`](references/lark-sheets-delete-dimension.md) | Delete rows or columns |
|
||||
| [`+create-filter-view`](references/lark-sheets-create-filter-view.md) | Create a filter view |
|
||||
| [`+update-filter-view`](references/lark-sheets-update-filter-view.md) | Update a filter view |
|
||||
| [`+list-filter-views`](references/lark-sheets-list-filter-views.md) | List all filter views in a sheet |
|
||||
| [`+get-filter-view`](references/lark-sheets-get-filter-view.md) | Get a filter view by ID |
|
||||
| [`+delete-filter-view`](references/lark-sheets-delete-filter-view.md) | Delete a filter view |
|
||||
| [`+create-filter-view-condition`](references/lark-sheets-create-filter-view-condition.md) | Create a filter condition on a filter view |
|
||||
| [`+update-filter-view-condition`](references/lark-sheets-update-filter-view-condition.md) | Update a filter condition |
|
||||
| [`+list-filter-view-conditions`](references/lark-sheets-list-filter-view-conditions.md) | List all filter conditions of a filter view |
|
||||
| [`+get-filter-view-condition`](references/lark-sheets-get-filter-view-condition.md) | Get a filter condition by column |
|
||||
| [`+delete-filter-view-condition`](references/lark-sheets-delete-filter-view-condition.md) | Delete a filter condition |
|
||||
|
||||
### 下拉列表
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+set-dropdown`](references/lark-sheets-set-dropdown.md) | 设置下拉列表(`multipleValue` 写入的前置步骤) |
|
||||
| [`+update-dropdown`](references/lark-sheets-update-dropdown.md) | 更新下拉列表选项 |
|
||||
| [`+get-dropdown`](references/lark-sheets-get-dropdown.md) | 查询下拉列表配置 |
|
||||
| [`+delete-dropdown`](references/lark-sheets-delete-dropdown.md) | 删除下拉列表 |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
# sheets +create-filter-view-condition(创建筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +create-filter-view-condition`。
|
||||
|
||||
为筛选视图的指定列创建筛选条件。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 数值筛选:E 列 < 6
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]'
|
||||
|
||||
# 文本筛选:G 列以 a 开头
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
| `--filter-type` | 是 | 筛选类型:`hiddenValue`、`number`、`text`、`color` |
|
||||
| `--compare-type` | 否 | 比较运算符(如 `less`、`beginsWith`、`between`) |
|
||||
| `--expected` | 是 | 筛选值 JSON 数组(如 `["6"]` 或 `["2","10"]`) |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `condition`(condition_id, filter_type, compare_type, expected)。
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
# sheets +create-filter-view(创建筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +create-filter-view`。
|
||||
|
||||
在工作表中创建筛选视图,每个工作表最多 150 个筛选视图。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14"
|
||||
|
||||
# 指定名称
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14" --filter-view-name "我的筛选"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--range` | 是 | 筛选范围(如 `sheetId!A1:H14`) |
|
||||
| `--filter-view-name` | 否 | 显示名称(最多 100 字符) |
|
||||
| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID(不传则自动生成) |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `filter_view`(filter_view_id, filter_view_name, range)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-list-filter-views](lark-sheets-list-filter-views.md) — 查询所有筛选视图
|
||||
- [lark-sheets-create-filter-view-condition](lark-sheets-create-filter-view-condition.md) — 添加筛选条件
|
||||
46
skills/lark-sheets/references/lark-sheets-delete-dropdown.md
Normal file
46
skills/lark-sheets/references/lark-sheets-delete-dropdown.md
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
# sheets +delete-dropdown(删除下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-dropdown`。
|
||||
|
||||
删除指定范围的下拉列表配置。支持一次删除多个范围。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**删除操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除单个范围
|
||||
lark-cli sheets +delete-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--ranges '["<sheetId>!A2:A100"]'
|
||||
|
||||
# 删除多个范围
|
||||
lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--ranges '["<sheetId>!A2:A100", "<sheetId>!C1:C50"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A2:A100"]'`),单个范围最多 5000 格,单次最多 100 个范围 |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含:
|
||||
|
||||
- `rangeResults[].range` — 对应的范围
|
||||
- `rangeResults[].success` — 是否成功
|
||||
- `rangeResults[].updatedCells` — 影响的单元格数量
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
|
||||
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
|
||||
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
# sheets +delete-filter-view-condition(删除筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-filter-view-condition`。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**破坏性写入操作** —— 删除后不可恢复。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
# sheets +delete-filter-view(删除筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-filter-view`。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**破坏性写入操作** —— 删除后不可恢复。执行前必须确认用户意图。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
89
skills/lark-sheets/references/lark-sheets-formula.md
Normal file
89
skills/lark-sheets/references/lark-sheets-formula.md
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
# 飞书表格公式规则
|
||||
|
||||
> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill,普通公式对区域默认"投影"(只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。
|
||||
|
||||
## 写入方式
|
||||
|
||||
公式必须使用对象格式写入(参见 SKILL.md「单元格数据类型」):
|
||||
|
||||
```bash
|
||||
--values '[[{"type":"formula","text":"=SUM(A1:A10)"}]]'
|
||||
```
|
||||
|
||||
## ARRAYFORMULA 判断流程
|
||||
|
||||
1. 结果是**标量**(单值)→ 不需要
|
||||
2. 结果是**数组**,且公式中**有**原生数组函数 → 不需要(数组语义自动传播)
|
||||
3. 结果是**数组**,且公式中**无**原生数组函数,对区域做标量计算 → 加 `ARRAYFORMULA`
|
||||
|
||||
```text
|
||||
# 有原生数组函数,无需包裹
|
||||
=FILTER(A2:A10,B2:B10="x")+1 ✓
|
||||
=XLOOKUP(E2:E10,A2:A10,B2:B10)*100 ✓
|
||||
=MAP(A2:A10,LAMBDA(x,x*2))-1 ✓
|
||||
|
||||
# 无原生数组函数,必须包裹
|
||||
=ARRAYFORMULA(A2:A100*B2:B100) ✓
|
||||
=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))✓
|
||||
```
|
||||
|
||||
## 原生数组函数清单(无需 ARRAYFORMULA)
|
||||
|
||||
`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP`
|
||||
|
||||
## 高风险函数:INDEX / OFFSET / ROW / COLUMN / MATCH
|
||||
|
||||
行号/列号/偏移量本身是数组时,必须显式包裹:
|
||||
|
||||
```text
|
||||
=ARRAYFORMULA(INDEX(...))
|
||||
=ARRAYFORMULA(ROW(...))
|
||||
```
|
||||
|
||||
例外:结果直接交给聚合函数消费时不需要:`=SUM(INDEX(A1:B2,0,1))` ✓
|
||||
|
||||
## 隐式逐项求值 → MAP/LAMBDA
|
||||
|
||||
Excel 中 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 MAP 显式遍历:
|
||||
|
||||
```text
|
||||
# Excel
|
||||
=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))
|
||||
|
||||
# 飞书
|
||||
=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUBTOTAL(103,INDIRECT("E"&r)))))
|
||||
```
|
||||
|
||||
同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 MAP。
|
||||
|
||||
## 多维结果降维
|
||||
|
||||
飞书公式结果只能是二维,不能返回"区域的列表"。合并多个区域时:
|
||||
|
||||
| 需求 | 写法 |
|
||||
|------|------|
|
||||
| 上下堆叠 | `=VSTACK(a, b, c)` |
|
||||
| 左右拼接 | `=HSTACK(a, b, c)` |
|
||||
| 压成单列 | `=TOCOL(...)` |
|
||||
| 压成单行 | `=TOROW(...)` |
|
||||
| 归约为标量 | `=REDUCE(init, arr, LAMBDA(acc, x, ...))` |
|
||||
|
||||
## 日期差
|
||||
|
||||
| 需求 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 天数差 | `=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` | `=DAY(B2-A2)` |
|
||||
| 月份差 | `=DATEDIF(A2,B2,"M")` | `=MONTH(B2-A2)` |
|
||||
| 年份差 | `=DATEDIF(A2,B2,"Y")` | `=YEAR(B2-A2)` |
|
||||
| 工作日差 | `=NETWORKDAYS(A2,B2)` | — |
|
||||
|
||||
## 飞书不支持的 Excel 语法
|
||||
|
||||
| Excel 语法 | 飞书替代 |
|
||||
|-----------|---------|
|
||||
| `=@A1:A10`(隐式交叉) | `=A1:A10`(飞书默认投影,去掉 `@`) |
|
||||
| `=A1#`(spill range) | 改成明确范围,或用 `TAKE`/`DROP`/`ARRAY_CONSTRAIN` |
|
||||
| `=SUM(Table1[Amount])`(结构化引用) | `=SUM(A2:A100)`(改为 A1 区域) |
|
||||
| `{=A1:A10*B1:B10}`(CSE 花括号) | `=ARRAYFORMULA(A1:A10*B1:B10)` |
|
||||
| `STOCKHISTORY` / `WEBSERVICE` / `CUBE*` | 飞书无等价函数 |
|
||||
43
skills/lark-sheets/references/lark-sheets-get-dropdown.md
Normal file
43
skills/lark-sheets/references/lark-sheets-get-dropdown.md
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
# sheets +get-dropdown(查询下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +get-dropdown`。
|
||||
|
||||
查询指定范围内已配置的下拉列表设置,包括选项值、是否多选、颜色映射等。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100"
|
||||
|
||||
lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含:
|
||||
|
||||
- `dataValidations[].conditionValues` — 下拉选项列表
|
||||
- `dataValidations[].ranges` — 应用范围
|
||||
- `dataValidations[].options.multipleValues` — 是否多选
|
||||
- `dataValidations[].options.highlightValidData` — 是否着色
|
||||
- `dataValidations[].options.colorValueMap` — 选项颜色映射
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
|
||||
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
|
||||
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
# sheets +get-filter-view-condition(获取筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +get-filter-view-condition`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `condition`(condition_id, filter_type, compare_type, expected)。
|
||||
26
skills/lark-sheets/references/lark-sheets-get-filter-view.md
Normal file
26
skills/lark-sheets/references/lark-sheets-get-filter-view.md
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
# sheets +get-filter-view(获取筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +get-filter-view`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `filter_view`(filter_view_id, filter_view_name, range)。
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
# sheets +list-filter-view-conditions(查询筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +list-filter-view-conditions`。
|
||||
|
||||
查询筛选视图的所有筛选条件。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `items[]`(condition_id, filter_type, compare_type, expected)。
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
# sheets +list-filter-views(查询筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +list-filter-views`。
|
||||
|
||||
查询工作表中的所有筛选视图,返回视图 ID、名称和范围。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `items[]`(filter_view_id, filter_view_name, range)。
|
||||
62
skills/lark-sheets/references/lark-sheets-set-dropdown.md
Normal file
62
skills/lark-sheets/references/lark-sheets-set-dropdown.md
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
# sheets +set-dropdown(设置下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +set-dropdown`。
|
||||
|
||||
为指定范围的单元格配置下拉列表选项。**这是使用 `multipleValue` 格式写入数据的前置步骤**——未配置下拉选项的单元格,`multipleValue` 写入会变成纯文本。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 基础:设置单选下拉
|
||||
lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]'
|
||||
|
||||
# 多选 + 颜色高亮
|
||||
lark-cli sheets +set-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' \
|
||||
--multiple --highlight --colors '["#1FB6C1", "#F006C2", "#FB16C3"]'
|
||||
|
||||
# 仅预览参数(不发请求)
|
||||
lark-cli sheets +set-dropdown --url "https://..." --range "..." --condition-values '...' --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`),单次最多 5000 行 x 100 列 |
|
||||
| `--condition-values` | 是 | 下拉选项,JSON 数组(如 `'["选项1","选项2"]'`),最多 500 个,每个 ≤100 字符,不能包含逗号 |
|
||||
| `--multiple` | 否 | 是否多选,默认 false |
|
||||
| `--highlight` | 否 | 是否着色,默认 false |
|
||||
| `--colors` | 否 | RGB 十六进制颜色 JSON 数组,需与 `--condition-values` 一一对应(`--highlight` 时必填) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `code`(0=成功)和 `msg`。
|
||||
|
||||
## 典型流程
|
||||
|
||||
```bash
|
||||
# 1. 先配置下拉选项
|
||||
lark-cli sheets +set-dropdown --url "<url>" \
|
||||
--range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple
|
||||
|
||||
# 2. 再用 multipleValue 写入
|
||||
lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \
|
||||
--values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
|
||||
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
|
||||
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表
|
||||
51
skills/lark-sheets/references/lark-sheets-update-dropdown.md
Normal file
51
skills/lark-sheets/references/lark-sheets-update-dropdown.md
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
# sheets +update-dropdown(更新下拉列表)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +update-dropdown`。
|
||||
|
||||
更新已有下拉列表的选项、颜色等配置。可同时更新多个范围。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" \
|
||||
--ranges '["<sheetId>!A1:A100", "<sheetId>!C1:C100"]' \
|
||||
--condition-values '["新选项1", "新选项2", "新选项3"]'
|
||||
|
||||
# 更新为多选 + 着色
|
||||
lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" \
|
||||
--ranges '["<sheetId>!A1:A100"]' \
|
||||
--condition-values '["选项A", "选项B"]' \
|
||||
--multiple --highlight --colors '["#1FB6C1", "#F006C2"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A1:A100"]'`) |
|
||||
| `--condition-values` | 是 | 新的下拉选项,JSON 数组 |
|
||||
| `--multiple` | 否 | 是否多选,默认 false |
|
||||
| `--highlight` | 否 | 是否着色,默认 false |
|
||||
| `--colors` | 否 | RGB 颜色 JSON 数组,需与 `--condition-values` 一一对应 |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `spreadsheetToken`、`sheetId`、`dataValidation`(选项值和颜色映射)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
|
||||
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
|
||||
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
# sheets +update-filter-view-condition(更新筛选条件)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +update-filter-view-condition`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \
|
||||
--filter-type "number" --compare-type "between" --expected '["2","10"]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母(如 `E`) |
|
||||
| `--filter-type` | 否 | 筛选类型 |
|
||||
| `--compare-type` | 否 | 比较运算符 |
|
||||
| `--expected` | 否 | 筛选值 JSON 数组 |
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
# sheets +update-filter-view(更新筛选视图)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +update-filter-view`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--range` | 否 | 新的筛选范围 |
|
||||
| `--filter-view-name` | 否 | 新的显示名称 |
|
||||
@@ -12,7 +12,9 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
> **搜索技巧**:如果用户的查询只指定了任务名称(例如“完成任务龙虾一号”),请直接使用 `+get-my-tasks --query "龙虾一号"` 命令搜索(不要带 `--complete` 参数,这样可以同时搜索未完成和已完成的任务)。
|
||||
> **任务搜索技巧**:先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**查询关键字**(例如任务名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了任务查询关键字,则目标是**任务**时优先使用 `+search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“今年以来”“已完成”“由我创建”“我关注的”),并且使用 `+search` 与 `+get-related-tasks` / `+get-my-tasks` 都能达到目的时,应优先使用列表型能力,而不是搜索型能力。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索。
|
||||
> **任务清单搜索技巧**:任务清单也遵循同样的判断逻辑。先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**清单查询关键字**(例如清单名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了清单查询关键字,则优先使用 `+tasklist-search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),并且使用搜索或原生列取清单都能达到目的时,应优先使用原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。
|
||||
> **意图区分补充**:像“搜索飞书中今年以来我关注的任务”这类表达,虽然字面带有“搜索”,但如果没有真正的查询关键字,且本质是在限定“与我相关 + 时间范围”,则应优先走 `+get-related-tasks`;像“搜索飞书中由我创建的任务清单”这类表达,如果没有清单关键字,且本质是在限定“清单范围 + 创建者”,则应优先走原生 `tasklists.list` 后筛选,而不是直接走搜索型 shortcut。
|
||||
> **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。
|
||||
> **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。
|
||||
> **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。
|
||||
@@ -24,7 +26,8 @@ metadata:
|
||||
|
||||
> **查询注意**:
|
||||
> 1. 在输出任务详情时,如果需要渲染负责人、创建人等人员字段,除了展示 `id` (例如 open_id) 外,还必须通过其他方式(例如调用通讯录技能)尝试获取并展示这个人的真实名字,以便用户更容易识别。
|
||||
> 2. 在输出任务详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。
|
||||
> 2. 在输出清单详情时,如果需要渲染 owner、member、角色成员等人员字段,也必须像任务成员展示一样,除了展示 `id` 外,尽量解析并展示对应人员的真实名字。
|
||||
> 3. 在输出任务或清单详情时,如果需要渲染创建时间、截止时间等字段,需要使用本地时区来渲染(格式为2006-01-02 15:04:05)。
|
||||
|
||||
> **Task GUID 定义**:
|
||||
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识(GUID),不是客户端展示的任务编号(例如 `t104121` / `suite_entity_num`)。
|
||||
@@ -41,7 +44,12 @@ metadata:
|
||||
- [`+followers`](./references/lark-task-followers.md) — Manage task followers
|
||||
- [`+reminder`](./references/lark-task-reminder.md) — Manage task reminders
|
||||
- [`+get-my-tasks`](./references/lark-task-get-my-tasks.md) — List tasks assigned to me
|
||||
- [`+get-related-tasks`](./references/lark-task-get-related-tasks.md) — List tasks related to me
|
||||
- [`+search`](./references/lark-task-search.md) — Search tasks
|
||||
- [`+subscribe-event`](./references/lark-task-subscribe-event.md) — Subscribe to task events
|
||||
- [`+set-ancestor`](./references/lark-task-set-ancestor.md) — Set or clear a task ancestor
|
||||
- [`+tasklist-create`](./references/lark-task-tasklist-create.md) — Create a tasklist and batch add tasks
|
||||
- [`+tasklist-search`](./references/lark-task-tasklist-search.md) — Search tasklists
|
||||
- [`+tasklist-task-add`](./references/lark-task-tasklist-task-add.md) — Add existing tasks to a tasklist
|
||||
- [`+tasklist-members`](./references/lark-task-tasklist-members.md) — Manage tasklist members
|
||||
|
||||
@@ -83,6 +91,15 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
- `add` — 添加任务成员
|
||||
- `remove` — 移除任务成员
|
||||
|
||||
### sections
|
||||
|
||||
- `create` — 创建自定义分组
|
||||
- `delete` — 删除自定义分组
|
||||
- `get` — 获取自定义分组详情
|
||||
- `list` — 获取自定义分组列表
|
||||
- `patch` — 更新自定义分组
|
||||
- `tasks` — 获取自定义分组任务列表
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
@@ -104,3 +121,9 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
| `subtasks.list` | `task:task:read` |
|
||||
| `members.add` | `task:task:write` |
|
||||
| `members.remove` | `task:task:write` |
|
||||
| `sections.create` | `task:section:write` |
|
||||
| `sections.delete` | `task:section:write` |
|
||||
| `sections.get` | `task:section:read` |
|
||||
| `sections.list` | `task:section:read` |
|
||||
| `sections.patch` | `task:section:write` |
|
||||
| `sections.tasks` | `task:section:read` |
|
||||
|
||||
@@ -38,7 +38,7 @@ lark-cli task +create --summary "Test Task" --dry-run
|
||||
## Workflow
|
||||
|
||||
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
|
||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status --json` or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
|
||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
|
||||
2. Execute `lark-cli task +create --summary "..." ...`
|
||||
3. Report the result: task ID and summary.
|
||||
|
||||
|
||||
53
skills/lark-task/references/lark-task-get-related-tasks.md
Normal file
53
skills/lark-task/references/lark-task-get-related-tasks.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# task +get-related-tasks
|
||||
|
||||
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
|
||||
>
|
||||
> **⚠️ Note:** This API must be called with a user identity. **Do NOT use an app identity, otherwise the call will fail.**
|
||||
>
|
||||
> **Pagination / Time Cursor Rule:**
|
||||
> In `+get-related-tasks`, `page_token` is the task `updated_at` cursor in microseconds.
|
||||
>
|
||||
> **Execution Priority:**
|
||||
> 1. If the request contains a start/end time boundary (for example, "今年以来", "最近一个月", "从 3 月 1 日开始"), first convert the **start time** boundary to a microsecond `page_token` and query from that token.
|
||||
> 2. Continue pagination using returned `page_token` until `has_more=false`, but never exceed 40 total page fetches.
|
||||
> 3. Do NOT default to `--page-all` for time-bounded queries.
|
||||
>
|
||||
> Only use `--page-all` from the beginning when:
|
||||
> 1. the user explicitly asks for a full scan of all related tasks, or
|
||||
> 2. no time boundary can be inferred from the request.
|
||||
|
||||
List tasks related to the current user.
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
# List all related tasks
|
||||
lark-cli task +get-related-tasks
|
||||
|
||||
# List incomplete related tasks starting from a page token
|
||||
lark-cli task +get-related-tasks --include-complete=false --page-token "1752730590582902"
|
||||
|
||||
# Show only tasks created by me
|
||||
lark-cli task +get-related-tasks --created-by-me
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--include-complete=<bool>` | No | Default behavior includes completed tasks. Set to `false` to keep only incomplete tasks. |
|
||||
| `--page-all` | No | Automatically paginate through all pages (max 40). |
|
||||
| `--page-limit <int>` | No | Max page limit (default 20). |
|
||||
| `--page-token <string>` | No | Start from the specified page token. This token is the task's last update time cursor in microseconds. |
|
||||
| `--created-by-me` | No | Keep only tasks whose creator is the current user. This is a client-side filter applied after fetching related-task pages. |
|
||||
| `--followed-by-me` | No | Keep only tasks followed by the current user. This is a client-side filter applied after fetching related-task pages. |
|
||||
|
||||
> **Page Token Note:** In `+get-related-tasks`, the `page_token` is a microsecond-level cursor representing the task's last update time. For example, `1752730590582902` should be treated as an updated-at cursor, not a task ID.
|
||||
>
|
||||
> **Pagination Note for Client-side Filters:** When `--created-by-me` or `--followed-by-me` is used, filtering happens locally after each upstream related-task page is fetched. The returned `has_more` and `page_token` still describe the upstream cursor, so later pages may contain more matching tasks, or may contain none.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Determine whether the user needs all related tasks or a filtered subset.
|
||||
2. Execute `lark-cli task +get-related-tasks ...`
|
||||
3. Report the matching tasks and, if present, the next `page_token`.
|
||||
41
skills/lark-task/references/lark-task-search.md
Normal file
41
skills/lark-task/references/lark-task-search.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# task +search
|
||||
|
||||
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
|
||||
>
|
||||
> **⚠️ Note:** This API must be called with a user identity. **Do NOT use an app identity, otherwise the call will fail.**
|
||||
|
||||
Search tasks by keyword and optional filters.
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
# Search by keyword
|
||||
lark-cli task +search --query "test"
|
||||
|
||||
# Search incomplete tasks assigned to specific users
|
||||
lark-cli task +search --assignee "ou_xxx,ou_yyy" --completed=false
|
||||
|
||||
# Search by due time range
|
||||
lark-cli task +search --query "release" --due "-1d,+7d"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--query <string>` | No | Search keyword. If omitted, at least one filter must be provided. |
|
||||
| `--creator <ids>` | No | Creator open_ids, comma-separated. |
|
||||
| `--assignee <ids>` | No | Assignee open_ids, comma-separated. |
|
||||
| `--follower <ids>` | No | Follower open_ids, comma-separated. |
|
||||
| `--completed=<bool>` | No | Filter by completion state. |
|
||||
| `--due <range>` | No | Due time range in `start,end` form. Each side supports ISO/date/relative/ms input. |
|
||||
| `--page-token <string>` | No | Page token for pagination. |
|
||||
| `--page-all` | No | Automatically paginate through all pages (max 40). |
|
||||
| `--page-limit <int>` | No | Max page limit (default 20). |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Build the keyword and filters from the user's request.
|
||||
2. Execute `lark-cli task +search ...`
|
||||
3. Report the matched tasks and include the next `page_token` if more results exist.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user