mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
30 Commits
v1.0.8
...
fix/format
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ee0defab | ||
|
|
2a301246f9 | ||
|
|
abc374f1a3 | ||
|
|
2910cde73a | ||
|
|
7fdc162ff7 | ||
|
|
06e7ae267c | ||
|
|
74f7de386a | ||
|
|
c2b132945e | ||
|
|
88fd3bdab8 | ||
|
|
c70c3fdce2 | ||
|
|
c13f240b9b | ||
|
|
88bf7fc1cd | ||
|
|
25534d72b5 | ||
|
|
815db0c866 | ||
|
|
bb7957245b | ||
|
|
3917b77e91 | ||
|
|
dc0d92708b | ||
|
|
085ffd87f3 | ||
|
|
f6b8091843 | ||
|
|
0e7f507efb | ||
|
|
1ff2dc578e | ||
|
|
69ae326d01 | ||
|
|
e07842d3b5 | ||
|
|
a9c07cebb6 | ||
|
|
f6a31e0853 | ||
|
|
bd5a33c0b7 | ||
|
|
3242ca6f7f | ||
|
|
368ec7e753 | ||
|
|
9f81e7e567 | ||
|
|
a00dfad56a |
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,47 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
- Add attendance `user_task.query` (#405)
|
||||
- Support minutes search (#359)
|
||||
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
|
||||
- **slides**: Return presentation URL in slides `+create` output (#425)
|
||||
- **sheets**: Add dimension shortcuts for row/column operations (#413)
|
||||
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
|
||||
- **drive**: Add drive folder delete shortcut with async task polling (#415)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Add guide for granting document permission to current bot (#414)
|
||||
|
||||
## [v1.0.8] - 2026-04-10
|
||||
|
||||
### Features
|
||||
@@ -287,6 +328,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
|
||||
10
README.md
10
README.md
@@ -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, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 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** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 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
|
||||
@@ -30,11 +30,13 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 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
|
||||
@@ -136,6 +138,7 @@ lark-cli auth status
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
@@ -147,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 |
|
||||
|
||||
10
README.zh.md
10
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 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 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -30,11 +30,13 @@
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
|
||||
## 安装与快速开始
|
||||
@@ -137,6 +139,7 @@ lark-cli auth status
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
@@ -148,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 == "" {
|
||||
|
||||
@@ -163,6 +163,16 @@ type CliConfig struct {
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
}
|
||||
|
||||
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
|
||||
// Must match extension/credential.SupportsBot.
|
||||
const identityBotBit uint8 = 1 << 1
|
||||
|
||||
// CanBot reports whether the current credential context supports bot identity.
|
||||
// Returns true when SupportedIdentities is unset (0, unknown) or includes the bot bit.
|
||||
func (c *CliConfig) CanBot() bool {
|
||||
return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0
|
||||
}
|
||||
|
||||
// GetConfigDir returns the config directory path.
|
||||
// If the home directory cannot be determined, it falls back to a relative path
|
||||
// and prints a warning to stderr.
|
||||
|
||||
@@ -187,3 +187,24 @@ func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
|
||||
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCliConfig_CanBot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
supportedIdentities uint8
|
||||
want bool
|
||||
}{
|
||||
{"unset (0) defaults to true", 0, true},
|
||||
{"user only", 1, false},
|
||||
{"bot only", 2, true},
|
||||
{"both", 3, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &CliConfig{SupportedIdentities: tt.supportedIdentities}
|
||||
if got := cfg.CanBot(); got != tt.want {
|
||||
t.Errorf("CanBot() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
)
|
||||
|
||||
@@ -17,13 +18,39 @@ var knownArrayFields = []string{
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
}
|
||||
|
||||
// isSliceLike reports whether v is any kind of slice (e.g. []interface{},
|
||||
// []map[string]interface{}, []string, etc.), using reflect so that the
|
||||
// check is not limited to a single concrete slice type.
|
||||
func isSliceLike(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return reflect.TypeOf(v).Kind() == reflect.Slice
|
||||
}
|
||||
|
||||
// toGenericSlice converts any slice type to []interface{} by re-boxing each
|
||||
// element. This only changes the outer container type; individual elements
|
||||
// retain their original dynamic type (e.g. map[string]interface{} stays as-is).
|
||||
// Returns nil if v is not a slice.
|
||||
func toGenericSlice(v interface{}) []interface{} {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Slice {
|
||||
return nil
|
||||
}
|
||||
out := make([]interface{}, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
out[i] = rv.Index(i).Interface()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
// It first checks knownArrayFields in priority order, then falls back to
|
||||
// the lexicographically smallest unknown array field for deterministic results.
|
||||
func FindArrayField(data map[string]interface{}) string {
|
||||
for _, name := range knownArrayFields {
|
||||
if arr, ok := data[name]; ok {
|
||||
if _, isArr := arr.([]interface{}); isArr {
|
||||
if isSliceLike(arr) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -31,7 +58,7 @@ func FindArrayField(data map[string]interface{}) string {
|
||||
// Fallback: lexicographically first array field (deterministic)
|
||||
var candidates []string
|
||||
for k, v := range data {
|
||||
if _, isArr := v.([]interface{}); isArr {
|
||||
if isSliceLike(v) {
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
}
|
||||
@@ -81,7 +108,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 1: Lark API envelope — result["data"][arrayField]
|
||||
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
|
||||
if field := FindArrayField(dataObj); field != "" {
|
||||
if items, ok := dataObj[field].([]interface{}); ok {
|
||||
if items := toGenericSlice(dataObj[field]); items != nil {
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -90,7 +117,7 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
// Strategy 2: direct map — result[arrayField]
|
||||
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
|
||||
if field := FindArrayField(resultMap); field != "" {
|
||||
if items, ok := resultMap[field].([]interface{}); ok {
|
||||
if items := toGenericSlice(resultMap[field]); items != nil {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,129 @@ func TestExtractItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Typed-slice regression tests ---
|
||||
// These cover the scenario where shortcut code uses []map[string]interface{}
|
||||
// (or other typed slices) instead of []interface{} in outData.
|
||||
|
||||
func TestExtractItems_TypedMapSlice(t *testing.T) {
|
||||
// Simulates shortcut pattern: outData["chats"] = []map[string]interface{}{...}
|
||||
data := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_abc", "name": "Test Chat"},
|
||||
{"chat_id": "oc_def", "name": "Dev Chat"},
|
||||
},
|
||||
"total": 2,
|
||||
"has_more": false,
|
||||
}
|
||||
items := ExtractItems(data)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items from typed map slice, got %d", len(items))
|
||||
}
|
||||
// Verify elements are still map[string]interface{} (flattenItem can handle them)
|
||||
for i, item := range items {
|
||||
if _, ok := item.(map[string]interface{}); !ok {
|
||||
t.Errorf("item[%d] should be map[string]interface{}, got %T", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractItems_TypedMapSlice_InEnvelope(t *testing.T) {
|
||||
// Typed slice inside a Lark API envelope: result["data"]["items"] = []map[string]interface{}{...}
|
||||
data := map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
}
|
||||
items := ExtractItems(data)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item from typed slice in envelope, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_Table_TypedMapSlice(t *testing.T) {
|
||||
// The core bug: --format table with []map[string]interface{} should render
|
||||
// multi-column table, not a key-value two-column fallback.
|
||||
data := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{
|
||||
{"chat_id": "oc_abc", "name": "Lark Dev"},
|
||||
},
|
||||
"total": 1,
|
||||
"has_more": false,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatTable)
|
||||
out := buf.String()
|
||||
|
||||
// Should have column headers from the data fields
|
||||
if !strings.Contains(out, "chat_id") {
|
||||
t.Errorf("table should contain 'chat_id' column header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "name") {
|
||||
t.Errorf("table should contain 'name' column header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Lark Dev") {
|
||||
t.Errorf("table should contain data value 'Lark Dev', got:\n%s", out)
|
||||
}
|
||||
// Should NOT render as key-value fallback (metadata as rows)
|
||||
if strings.Contains(out, "has_more") {
|
||||
t.Errorf("table should not contain metadata 'has_more' as a row, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_CSV_TypedMapSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"messages": []map[string]interface{}{
|
||||
{"message_id": "om_abc", "content": "hello"},
|
||||
{"message_id": "om_def", "content": "world"},
|
||||
},
|
||||
"total": 2,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatCSV)
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("CSV should have header + 2 rows, got %d lines:\n%s", len(lines), buf.String())
|
||||
}
|
||||
// Header should contain data field names, not top-level map keys
|
||||
header := lines[0]
|
||||
if !strings.Contains(header, "message_id") {
|
||||
t.Errorf("CSV header should contain 'message_id', got: %s", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_NDJSON_TypedMapSlice(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"tasks": []map[string]interface{}{
|
||||
{"guid": "t1", "url": "https://example.com/t1"},
|
||||
{"guid": "t2", "url": "https://example.com/t2"},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatNDJSON)
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String())
|
||||
}
|
||||
for i, line := range lines {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
t.Errorf("NDJSON line %d should be valid JSON: %s", i, line)
|
||||
}
|
||||
if _, ok := obj["guid"]; !ok {
|
||||
t.Errorf("NDJSON line %d should contain 'guid' field, got: %s", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_LegacyFormats(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@
|
||||
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
|
||||
"zh": { "title": "电子表格", "description": "电子表格操作" }
|
||||
},
|
||||
"slides": {
|
||||
"en": { "title": "Slides", "description": "Create and manage presentations, read content, and add or remove slides" },
|
||||
"zh": { "title": "幻灯片", "description": "创建和管理演示文稿、读取内容,以及新增或删除幻灯片页面" }
|
||||
},
|
||||
"task": {
|
||||
"en": { "title": "Task", "description": "Task, task list, and subtask management" },
|
||||
"zh": { "title": "任务", "description": "任务、清单、子任务管理" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.10",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -865,6 +865,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 +1055,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) {
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -120,6 +120,8 @@ func permissionTargetLabel(resourceType string) string {
|
||||
return "spreadsheet"
|
||||
case "bitable", "base":
|
||||
return "base"
|
||||
case "slides":
|
||||
return "presentation"
|
||||
case "file":
|
||||
return "file"
|
||||
case "folder":
|
||||
|
||||
@@ -42,6 +42,7 @@ type RuntimeContext struct {
|
||||
resolvedAs core.Identity // effective identity resolved by framework
|
||||
Factory *cmdutil.Factory // injected by framework
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
}
|
||||
|
||||
@@ -71,6 +72,57 @@ func (ctx *RuntimeContext) IsBot() bool {
|
||||
// UserOpenId returns the current user's open_id from config.
|
||||
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||
|
||||
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
|
||||
type BotInfo struct {
|
||||
OpenID string
|
||||
AppName string
|
||||
}
|
||||
|
||||
// BotInfo returns the bot's open_id and display name, fetched lazily from /bot/v3/info.
|
||||
// Unlike UserOpenId() (which reads from config), this requires a network call and may fail.
|
||||
// Thread-safe via sync.OnceValues; the API is called at most once per RuntimeContext.
|
||||
func (ctx *RuntimeContext) BotInfo() (*BotInfo, error) {
|
||||
if ctx.botInfoFunc == nil {
|
||||
return nil, fmt.Errorf("BotInfo not available (runtime context not fully initialized)")
|
||||
}
|
||||
return ctx.botInfoFunc()
|
||||
}
|
||||
|
||||
// fetchBotInfo calls /bot/v3/info using bot identity and parses the response.
|
||||
func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
|
||||
if !ctx.Config.CanBot() {
|
||||
return nil, fmt.Errorf("fetch bot info: bot identity is not available in current credential context")
|
||||
}
|
||||
resp, err := ctx.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/bot/v3/info",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch bot info: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("fetch bot info: [%d] %s", envelope.Code, envelope.Msg)
|
||||
}
|
||||
if envelope.Data.OpenID == "" {
|
||||
return nil, fmt.Errorf("fetch bot info: open_id is empty")
|
||||
}
|
||||
return &BotInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
|
||||
}
|
||||
|
||||
// Ctx returns the context.Context propagated from cmd.Context().
|
||||
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
|
||||
|
||||
@@ -639,6 +691,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
||||
return f.NewAPIClientWithConfig(config)
|
||||
})
|
||||
rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo)
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
|
||||
297
shortcuts/common/runner_botinfo_test.go
Normal file
297
shortcuts/common/runner_botinfo_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// botInfoTestConfig returns a CliConfig suitable for bot info tests.
|
||||
func botInfoTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
// runBotInfoShortcut mounts a shortcut that calls BotInfo() and executes it.
|
||||
// The shortcut stores the result (or error) in the provided pointers.
|
||||
func runBotInfoShortcut(t *testing.T, f *cmdutil.Factory, gotInfo **BotInfo, gotErr *error) {
|
||||
t.Helper()
|
||||
s := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+bot-info",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
info, err := rctx.BotInfo()
|
||||
*gotInfo = info
|
||||
*gotErr = err
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+bot-info", "--as", "bot"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("shortcut execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_Success(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_bot_abc123",
|
||||
"app_name": "TestBot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info.OpenID != "ou_bot_abc123" {
|
||||
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_abc123")
|
||||
}
|
||||
if info.AppName != "TestBot" {
|
||||
t.Errorf("AppName = %q, want %q", info.AppName, "TestBot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_bot_header",
|
||||
"app_name": "HeaderBot",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Verify shortcut context headers were injected
|
||||
if stub.CapturedHeaders.Get("X-Cli-Shortcut") == "" {
|
||||
t.Error("missing X-Cli-Shortcut header on /bot/v3/info request")
|
||||
}
|
||||
if stub.CapturedHeaders.Get("X-Cli-Execution-Id") == "" {
|
||||
t.Error("missing X-Cli-Execution-Id header on /bot/v3/info request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_OnceSemantics(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
// Only register one stub — if fetchBotInfo is called twice, the second call
|
||||
// would fail with "no stub" since the first stub is already matched.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_bot_once",
|
||||
"app_name": "OnceBot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+bot-info-once",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
// Call BotInfo twice — second should use cached result
|
||||
_, _ = rctx.BotInfo()
|
||||
info, err := rctx.BotInfo()
|
||||
if err != nil {
|
||||
t.Errorf("second BotInfo() call failed: %v", err)
|
||||
}
|
||||
if info.OpenID != "ou_bot_once" {
|
||||
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_once")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+bot-info-once", "--as", "bot"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("shortcut execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[99991]") {
|
||||
t.Errorf("error = %q, want substring [99991]", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "",
|
||||
"app_name": "EmptyBot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty open_id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "open_id is empty") {
|
||||
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: 403,
|
||||
Body: map[string]interface{}{"code": 403, "msg": "forbidden"},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error = %q, want substring '403'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper
|
||||
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
||||
cfg := botInfoTestConfig(t)
|
||||
cfg.SupportedIdentities = 1 // user only
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
// Use a dual-auth shortcut running as user, calling BotInfo() internally.
|
||||
// No /bot/v3/info stub — CanBot should short-circuit before API call.
|
||||
var info *BotInfo
|
||||
var err error
|
||||
s := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+bot-info-canbot",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
i, e := rctx.BotInfo()
|
||||
info = i
|
||||
err = e
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+bot-info-canbot", "--as", "user"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if execErr := parent.Execute(); execErr != nil {
|
||||
t.Fatalf("shortcut execution failed: %v", execErr)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when bot identity not available")
|
||||
}
|
||||
if info != nil {
|
||||
t.Errorf("expected nil info, got %+v", info)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotInfo_NilFunc(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
rctx := TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
_, err := rctx.BotInfo()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil botInfoFunc")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not fully initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -27,3 +28,12 @@ func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *
|
||||
func TestNewRuntimeContextWithIdentity(cmd *cobra.Command, cfg *core.CliConfig, as core.Identity) *RuntimeContext {
|
||||
return &RuntimeContext{Cmd: cmd, Config: cfg, resolvedAs: as}
|
||||
}
|
||||
|
||||
// TestNewRuntimeContextWithBotInfo creates a RuntimeContext with a pre-set BotInfo for testing.
|
||||
func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, info *BotInfo) *RuntimeContext {
|
||||
rctx := &RuntimeContext{Cmd: cmd, Config: cfg}
|
||||
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
|
||||
return info, nil
|
||||
})
|
||||
return rctx
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
148
shortcuts/drive/drive_delete.go
Normal file
148
shortcuts/drive/drive_delete.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// 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 driveDeleteAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"folder": true,
|
||||
"shortcut": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
// driveDeleteSpec contains the normalized input needed to issue a delete
|
||||
// request against the Drive files endpoint.
|
||||
type driveDeleteSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
}
|
||||
|
||||
// DriveDelete deletes a Drive file or folder and handles the async task
|
||||
// polling required by folder deletes.
|
||||
var DriveDelete = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+delete",
|
||||
Description: "Delete a file or folder in Drive",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"space:document:delete"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
|
||||
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveDeleteSpec(driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Delete file or folder in Drive")
|
||||
|
||||
dry.DELETE("/open-apis/drive/v1/files/:file_token").
|
||||
Desc("[1] Delete file/folder").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"type": spec.FileType})
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[2] Poll async task status (for folder delete)").
|
||||
Params(driveTaskCheckParams("<task_id>"))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken))
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
|
||||
map[string]interface{}{"type": spec.FileType},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
|
||||
|
||||
status, ready, err := pollDriveTaskCheck(runtime, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"status": status.StatusLabel(),
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
"ready": ready,
|
||||
}
|
||||
if ready {
|
||||
out["deleted"] = true
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"deleted": true,
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
|
||||
}
|
||||
if !driveDeleteAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
224
shortcuts/drive/drive_delete_test.go
Normal file
224
shortcuts/drive/drive_delete_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveDeleteSpec(driveDeleteSpec{
|
||||
FileToken: "wiki_token_test",
|
||||
FileType: "wiki",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected wiki type error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wiki documents are not supported") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +delete"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "folder"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveDelete.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"`
|
||||
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) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "DELETE" {
|
||||
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Params["type"] != "folder" {
|
||||
t.Fatalf("delete params = %#v", got.API[0].Params)
|
||||
}
|
||||
if got.API[1].Params["task_id"] != "<task_id>" {
|
||||
t.Fatalf("task check params = %#v", got.API[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteRequiresYes(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteFileSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/file_token_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
|
||||
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
|
||||
t.Fatalf("stdout missing file token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskCheckBody map[string]interface{}
|
||||
wantErrContains string
|
||||
wantStdout []string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"task_id": "task_123"`,
|
||||
`"deleted": true`,
|
||||
`"ready": true`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "process"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"ready": false`,
|
||||
`"timed_out": true`,
|
||||
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "fail"},
|
||||
},
|
||||
wantErrContains: "folder task failed",
|
||||
},
|
||||
{
|
||||
name: "task_check error",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 1061001,
|
||||
"msg": "internal error",
|
||||
},
|
||||
wantErrContains: "internal error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/fld_src",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: tt.taskCheckBody,
|
||||
})
|
||||
|
||||
withSingleDriveTaskCheckPoll(t)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected delete failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, needle := range tt.wantStdout {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
|
||||
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveTaskCheckPollMu sync.Mutex
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -37,6 +40,18 @@ func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func withSingleDriveTaskCheckPoll(t *testing.T) {
|
||||
t.Helper()
|
||||
driveTaskCheckPollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := driveTaskCheckPollAttempts, driveTaskCheckPollInterval
|
||||
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = prevAttempts, prevInterval
|
||||
driveTaskCheckPollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func withDriveWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
|
||||
@@ -115,7 +115,7 @@ var DriveMove = common.Shortcut{
|
||||
"ready": ready,
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID)
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
driveMovePollAttempts = 30
|
||||
driveMovePollInterval = 2 * time.Second
|
||||
driveTaskCheckPollAttempts = 30
|
||||
driveTaskCheckPollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
|
||||
@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
|
||||
}
|
||||
|
||||
// driveTaskCheckStatus represents the status payload returned by
|
||||
// /drive/v1/files/task_check for async folder operations.
|
||||
// /drive/v1/files/task_check for async folder move/delete operations.
|
||||
type driveTaskCheckStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
@@ -72,7 +72,11 @@ func (s driveTaskCheckStatus) Ready() bool {
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Failed() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
|
||||
status := strings.TrimSpace(s.Status)
|
||||
// The shared task_check endpoint is reused by multiple async flows. Some
|
||||
// backends return "failed", while folder delete can return the shorter
|
||||
// terminal state "fail".
|
||||
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Pending() bool {
|
||||
@@ -91,8 +95,8 @@ func (s driveTaskCheckStatus) StatusLabel() string {
|
||||
|
||||
// driveTaskCheckResultCommand prints the resume command shown when bounded
|
||||
// polling ends before the backend task completes.
|
||||
func driveTaskCheckResultCommand(taskID string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
|
||||
func driveTaskCheckResultCommand(taskID, as string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s --as %s", taskID, as)
|
||||
}
|
||||
|
||||
// driveTaskCheckParams keeps the task_check query parameter shape in one place
|
||||
@@ -130,31 +134,42 @@ func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) drive
|
||||
}
|
||||
}
|
||||
|
||||
// pollDriveTaskCheck polls the backend for a bounded period and returns the
|
||||
// last seen status so callers can emit a follow-up command when needed.
|
||||
// pollDriveTaskCheck polls the shared task_check endpoint for a bounded period
|
||||
// and returns the last seen status so callers can emit a follow-up command
|
||||
// when needed.
|
||||
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
|
||||
lastStatus := driveTaskCheckStatus{TaskID: taskID}
|
||||
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
|
||||
var (
|
||||
seenStatus bool
|
||||
lastErr error
|
||||
)
|
||||
for attempt := 1; attempt <= driveTaskCheckPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(driveMovePollInterval)
|
||||
time.Sleep(driveTaskCheckPollInterval)
|
||||
}
|
||||
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
|
||||
continue
|
||||
}
|
||||
seenStatus = true
|
||||
lastStatus = status
|
||||
// Success and failure are terminal backend states. Any other value is kept
|
||||
// as pending so the caller can decide whether to continue or resume later.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n")
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed")
|
||||
}
|
||||
}
|
||||
|
||||
if !seenStatus && lastErr != nil {
|
||||
return driveTaskCheckStatus{}, false, lastErr
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
@@ -102,91 +102,91 @@ func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
func TestDriveMoveFolderTaskCheckOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskCheckBody map[string]interface{}
|
||||
wantErrContains string
|
||||
wantStdout []string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"task_id": "task_123"`,
|
||||
`"ready": true`,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
{
|
||||
name: "timeout",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"ready": false`,
|
||||
`"timed_out": true`,
|
||||
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all polls fail",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 1061001,
|
||||
"msg": "internal error",
|
||||
},
|
||||
wantErrContains: "internal error",
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
|
||||
t.Fatalf("stdout missing task id: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
|
||||
t.Fatalf("stdout missing ready=true: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: tt.taskCheckBody,
|
||||
})
|
||||
|
||||
withSingleDriveTaskCheckPoll(t)
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected task_check polling error, got nil")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte(tt.wantErrContains)) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, needle := range tt.wantStdout {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
|
||||
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -246,3 +256,290 @@ func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "fail"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "task_check",
|
||||
"--task-id", "task_123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "fail"`)) {
|
||||
t.Fatalf("stdout missing fail status: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": true`)) {
|
||||
t.Fatalf("stdout missing failed=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
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,12 +9,14 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
DriveUpload,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveDelete,
|
||||
DriveTaskResult,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,21 @@ 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",
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+task_result",
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
347
shortcuts/minutes/minutes_search.go
Normal file
347
shortcuts/minutes/minutes_search.go
Normal file
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMinutesSearchPageSize = 15
|
||||
maxMinutesSearchPageSize = 30
|
||||
maxMinutesSearchQueryLen = 50
|
||||
)
|
||||
|
||||
// parseTimeRange normalizes --start and --end into RFC3339 timestamps.
|
||||
func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
||||
start := strings.TrimSpace(runtime.Str("start"))
|
||||
end := strings.TrimSpace(runtime.Str("end"))
|
||||
if start == "" && end == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var startTime, endTime string
|
||||
if start != "" {
|
||||
parsed, err := toRFC3339(start)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
startTime = parsed
|
||||
}
|
||||
if end != "" {
|
||||
parsed, err := toRFC3339(end, "end")
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
endTime = parsed
|
||||
}
|
||||
if startTime != "" && endTime != "" {
|
||||
st, err := time.Parse(time.RFC3339, startTime)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse normalized --start: %w", err)
|
||||
}
|
||||
et, err := time.Parse(time.RFC3339, endTime)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse normalized --end: %w", err)
|
||||
}
|
||||
if st.After(et) {
|
||||
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
|
||||
}
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
// toRFC3339 converts a supported CLI time input into an RFC3339 timestamp.
|
||||
func toRFC3339(input string, hint ...string) (string, error) {
|
||||
ts, err := common.ParseTime(input, hint...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sec, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
|
||||
}
|
||||
return time.Unix(sec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// resolveUserIDs expands special user identifiers and removes duplicates.
|
||||
func resolveUserIDs(flagName string, ids []string, runtime *common.RuntimeContext) ([]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
currentUserID := runtime.UserOpenId()
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(id, "me") {
|
||||
if currentUserID == "" {
|
||||
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
}
|
||||
id = currentUserID
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildTimeFilter builds the create_time filter block for the API request.
|
||||
func buildTimeFilter(startTime, endTime string) map[string]interface{} {
|
||||
if startTime == "" && endTime == "" {
|
||||
return nil
|
||||
}
|
||||
timeRange := map[string]interface{}{}
|
||||
if startTime != "" {
|
||||
timeRange["start_time"] = startTime
|
||||
}
|
||||
if endTime != "" {
|
||||
timeRange["end_time"] = endTime
|
||||
}
|
||||
return timeRange
|
||||
}
|
||||
|
||||
// buildMinutesSearchFilter builds the filter object for the API request body.
|
||||
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
|
||||
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ownerIDs) > 0 {
|
||||
filter["owner_ids"] = ownerIDs
|
||||
}
|
||||
|
||||
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(participantIDs) > 0 {
|
||||
filter["participant_ids"] = participantIDs
|
||||
}
|
||||
|
||||
if timeRange := buildTimeFilter(startTime, endTime); timeRange != nil {
|
||||
filter["create_time"] = timeRange
|
||||
}
|
||||
|
||||
if len(filter) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
// buildMinutesSearchBody builds the POST body for the minutes search API.
|
||||
func buildMinutesSearchBody(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
body["query"] = q
|
||||
}
|
||||
|
||||
filter, err := buildMinutesSearchFilter(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter != nil {
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// buildMinutesSearchParams builds the query parameters for the search request.
|
||||
func buildMinutesSearchParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
|
||||
pageSize := strings.TrimSpace(runtime.Str("page-size"))
|
||||
if pageSize == "" {
|
||||
pageSize = fmt.Sprintf("%d", defaultMinutesSearchPageSize)
|
||||
}
|
||||
params["page_size"] = pageSize
|
||||
|
||||
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// minuteSearchItems extracts the result items from the API response payload.
|
||||
func minuteSearchItems(data map[string]interface{}) []interface{} {
|
||||
return common.GetSlice(data, "items")
|
||||
}
|
||||
|
||||
// minuteSearchToken extracts the minute token from a search result item.
|
||||
func minuteSearchToken(item map[string]interface{}) string {
|
||||
return common.GetString(item, "token")
|
||||
}
|
||||
|
||||
// minuteSearchDisplayInfo extracts the display_info field from a search result item.
|
||||
func minuteSearchDisplayInfo(item map[string]interface{}) string {
|
||||
return common.GetString(item, "display_info")
|
||||
}
|
||||
|
||||
// minuteSearchDescription extracts the description field from a search result item.
|
||||
func minuteSearchDescription(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "description")
|
||||
}
|
||||
|
||||
// minuteSearchAppLink extracts the app link from a search result item.
|
||||
func minuteSearchAppLink(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "app_link")
|
||||
}
|
||||
|
||||
// minuteSearchAvatar extracts the avatar URL from a search result item.
|
||||
func minuteSearchAvatar(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "avatar")
|
||||
}
|
||||
|
||||
// buildMinuteSearchRows converts API items into pretty output rows.
|
||||
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"token": minuteSearchToken(item),
|
||||
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
|
||||
"description": common.TruncateStr(minuteSearchDescription(item), 40),
|
||||
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
|
||||
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
|
||||
var MinutesSearch = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+search",
|
||||
Description: "Search minutes by keyword, owners, participants, and time range",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.search:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"},
|
||||
{Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"},
|
||||
{Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "page-token", Desc: "page token for next page"},
|
||||
{Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, _, err := parseTimeRange(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
|
||||
return output.ErrValidation("--query: length must be between 1 and 50 characters")
|
||||
}
|
||||
if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ownerIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range participantIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} {
|
||||
if strings.TrimSpace(runtime.Str(flag)) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
startTime, endTime, err := parseTimeRange(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildMinutesSearchParams(runtime)
|
||||
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
dryRun := common.NewDryRunAPI().
|
||||
POST("/open-apis/minutes/v1/minutes/search")
|
||||
if len(params) > 0 {
|
||||
dryRun.Params(params)
|
||||
}
|
||||
return dryRun.Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
startTime, endTime, err := parseTimeRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
|
||||
items := minuteSearchItems(data)
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
rows := buildMinuteSearchRows(items)
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": items,
|
||||
"total": data["total"],
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(w, "No minutes.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
691
shortcuts/minutes/minutes_search_test.go
Normal file
691
shortcuts/minutes/minutes_search_test.go
Normal file
@@ -0,0 +1,691 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newMinutesSearchTestCommand builds a command with the flags used by minutes search tests.
|
||||
func newMinutesSearchTestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("owner-ids", "", "")
|
||||
cmd.Flags().String("participant-ids", "", "")
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().String("page-size", "15", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// configWithoutUserOpenID returns a test config without a resolvable user open_id.
|
||||
func configWithoutUserOpenID() *core.CliConfig {
|
||||
cfg := defaultConfig()
|
||||
cfg.UserOpenId = ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestMinutesSearchParseTimeRange verifies valid time inputs are normalized.
|
||||
func TestMinutesSearchParseTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("start", "2026-03-24")
|
||||
_ = cmd.Flags().Set("end", "2026-03-25")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
start, end, err := parseTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRange() unexpected error: %v", err)
|
||||
}
|
||||
if start == "" || end == "" {
|
||||
t.Fatalf("expected non-empty start/end, got %q %q", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchParseTimeRangeErrors verifies invalid time inputs return validation errors.
|
||||
func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantMessage string
|
||||
}{
|
||||
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
|
||||
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
|
||||
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
if tt.start != "" {
|
||||
_ = cmd.Flags().Set("start", tt.start)
|
||||
}
|
||||
if tt.end != "" {
|
||||
_ = cmd.Flags().Set("end", tt.end)
|
||||
}
|
||||
|
||||
_, _, err := parseTimeRange(common.TestNewRuntimeContext(cmd, defaultConfig()))
|
||||
if err == nil {
|
||||
t.Fatal("expected parseTimeRange error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMinutesSearchParams verifies request params and body fields are assembled correctly.
|
||||
func TestBuildMinutesSearchParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
_ = cmd.Flags().Set("owner-ids", "ou_owner,ou_owner_2")
|
||||
_ = cmd.Flags().Set("participant-ids", "ou_c")
|
||||
_ = cmd.Flags().Set("page-size", "5")
|
||||
_ = cmd.Flags().Set("page-token", "next_page")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
params := buildMinutesSearchParams(runtime)
|
||||
body, err := buildMinutesSearchBody(runtime, "2026-03-24T00:00:00Z", "2026-03-25T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got, _ := params["page_size"].(string); got != "5" {
|
||||
t.Fatalf("page_size = %q, want 5", got)
|
||||
}
|
||||
if got, _ := params["page_token"].(string); got != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", got)
|
||||
}
|
||||
if body["query"] != "budget" {
|
||||
t.Fatalf("body.query = %v, want budget", body["query"])
|
||||
}
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil {
|
||||
t.Fatalf("body.filter = nil, want filter object")
|
||||
}
|
||||
owners, _ := filter["owner_ids"].([]string)
|
||||
if len(owners) != 2 || owners[0] != "ou_owner" || owners[1] != "ou_owner_2" {
|
||||
t.Fatalf("owner_ids = %v, want [ou_owner ou_owner_2]", filter["owner_ids"])
|
||||
}
|
||||
participants, _ := filter["participant_ids"].([]string)
|
||||
if len(participants) != 1 || participants[0] != "ou_c" {
|
||||
t.Fatalf("participant_ids = %v, want [ou_c]", filter["participant_ids"])
|
||||
}
|
||||
createTime, _ := filter["create_time"].(map[string]interface{})
|
||||
if createTime == nil {
|
||||
t.Fatalf("create_time = nil, want time range")
|
||||
}
|
||||
if createTime["start_time"] != "2026-03-24T00:00:00Z" {
|
||||
t.Fatalf("start_time = %v", createTime["start_time"])
|
||||
}
|
||||
if createTime["end_time"] != "2026-03-25T00:00:00Z" {
|
||||
t.Fatalf("end_time = %v", createTime["end_time"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMinutesSearchParamsDefaultPageSize verifies the default page size is applied.
|
||||
func TestBuildMinutesSearchParamsDefaultPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
|
||||
params := buildMinutesSearchParams(common.TestNewRuntimeContext(cmd, defaultConfig()))
|
||||
if got, _ := params["page_size"].(string); got != "15" {
|
||||
t.Fatalf("page_size = %q, want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUserIDs verifies me expansion, deduplication, and nil handling.
|
||||
func TestResolveUserIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
|
||||
got, err := resolveUserIDs("--owner-ids", []string{"me"}, runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserIDs([me]) unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "ou_testuser" {
|
||||
t.Fatalf("resolveUserIDs([me]) = %v, want [ou_testuser]", got)
|
||||
}
|
||||
|
||||
got, err = resolveUserIDs("--owner-ids", []string{"ou_other", "me", "Me"}, runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserIDs([ou_other, me, Me]) unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "ou_other" || got[1] != "ou_testuser" {
|
||||
t.Fatalf("resolveUserIDs([ou_other, me, Me]) = %v, want [ou_other ou_testuser]", got)
|
||||
}
|
||||
|
||||
got, err = resolveUserIDs("--owner-ids", nil, runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserIDs(nil) unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("resolveUserIDs(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTimeFilter verifies time filters are only populated for provided bounds.
|
||||
func TestBuildTimeFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := buildTimeFilter("", ""); got != nil {
|
||||
t.Fatalf("buildTimeFilter('', '') = %v, want nil", got)
|
||||
}
|
||||
if got := buildTimeFilter("2026-03-24T00:00:00Z", ""); got["start_time"] != "2026-03-24T00:00:00Z" {
|
||||
t.Fatalf("start_time = %v", got["start_time"])
|
||||
}
|
||||
if got := buildTimeFilter("", "2026-03-25T00:00:00Z"); got["end_time"] != "2026-03-25T00:00:00Z" {
|
||||
t.Fatalf("end_time = %v", got["end_time"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationMeOwnerID verifies owner-ids accepts me when open_id is available.
|
||||
func TestMinutesSearchValidationMeOwnerID(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("owner-ids", "me")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for --owner-ids me, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationMeRequiresResolvableUser verifies me fails without a resolvable open_id.
|
||||
func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
}{
|
||||
{name: "owner ids", flag: "owner-ids"},
|
||||
{name: "participant ids", flag: "participant-ids"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set(tt.flag, "me")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, configWithoutUserOpenID())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for unresolved me")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolvable open_id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMinutesSearchFilterMeExpansion verifies me is expanded inside the request filter.
|
||||
func TestBuildMinutesSearchFilterMeExpansion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("owner-ids", "me,ou_other")
|
||||
_ = cmd.Flags().Set("participant-ids", "me")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body, err := buildMinutesSearchBody(runtime, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil {
|
||||
t.Fatal("body.filter = nil, want filter object")
|
||||
}
|
||||
owners, _ := filter["owner_ids"].([]string)
|
||||
if len(owners) != 2 || owners[0] != "ou_testuser" || owners[1] != "ou_other" {
|
||||
t.Fatalf("owner_ids = %v, want [ou_testuser ou_other]", owners)
|
||||
}
|
||||
participants, _ := filter["participant_ids"].([]string)
|
||||
if len(participants) != 1 || participants[0] != "ou_testuser" {
|
||||
t.Fatalf("participant_ids = %v, want [ou_testuser]", participants)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchItems verifies items extraction from the search response payload.
|
||||
func TestMinuteSearchItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
items := minuteSearchItems(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"minute_token": "tok_1"}},
|
||||
})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("minuteSearchItems() len = %d, want 1", len(items))
|
||||
}
|
||||
|
||||
if got := minuteSearchItems(map[string]interface{}{}); got != nil {
|
||||
t.Fatalf("minuteSearchItems() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationNoFilter verifies at least one filter is required.
|
||||
func TestMinutesSearchValidationNoFilter(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty filters")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "specify at least one") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
|
||||
func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("participant-ids", "user_123")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid user ID error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
|
||||
func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("owner-ids", "user_123")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid owner ID error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
|
||||
func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", strings.Repeat("a", 51))
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected query length error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
|
||||
func TestMinutesSearchValidationMaxPageSize30(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
_ = cmd.Flags().Set("page-size", "30")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for --page-size 30, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationPageSizeAboveMax verifies page sizes above the limit are rejected.
|
||||
func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
_ = cmd.Flags().Set("page-size", "31")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --page-size 31")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--page-size") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
|
||||
func TestMinutesSearchValidationTimeErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantMessage string
|
||||
}{
|
||||
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
|
||||
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
|
||||
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
if tt.start != "" {
|
||||
_ = cmd.Flags().Set("start", tt.start)
|
||||
}
|
||||
if tt.end != "" {
|
||||
_ = cmd.Flags().Set("end", tt.end)
|
||||
}
|
||||
|
||||
err := MinutesSearch.Validate(context.Background(), common.TestNewRuntimeContext(cmd, defaultConfig()))
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchDryRun verifies dry-run output includes the expected API request details.
|
||||
func TestMinutesSearchDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "ou_owner,ou_owner_2", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/search") {
|
||||
t.Fatalf("dry-run should show API path, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"method\": \"POST\"") {
|
||||
t.Fatalf("dry-run should use POST, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"query\": \"budget\"") {
|
||||
t.Fatalf("dry-run should show query in body, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"owner_ids\": [") || !strings.Contains(stdout.String(), "\"ou_owner\"") {
|
||||
t.Fatalf("dry-run should show owner_ids in filter, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints.
|
||||
func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "周会摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": 1,
|
||||
"has_more": true,
|
||||
"page_token": "next_token",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "me", "--format", "pretty", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal request body: %v", err)
|
||||
}
|
||||
if body["query"] != "budget" {
|
||||
t.Fatalf("request query = %v, want budget", body["query"])
|
||||
}
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil {
|
||||
t.Fatalf("request filter = %v, want object", body["filter"])
|
||||
}
|
||||
owners, _ := filter["owner_ids"].([]interface{})
|
||||
if len(owners) != 1 || owners[0] != "ou_testuser" {
|
||||
t.Fatalf("request owner_ids = %v, want [ou_testuser]", filter["owner_ids"])
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message.
|
||||
func TestMinutesSearchExecuteNoMinutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "pretty", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if !strings.Contains(stdout.String(), "No minutes.") {
|
||||
t.Fatalf("expected no minutes message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints.
|
||||
func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "周会摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": 1,
|
||||
"has_more": true,
|
||||
"page_token": "next_token",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "table", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") {
|
||||
t.Fatalf("expected pagination hint in table output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteJSONCountUsesRenderedRows verifies JSON metadata counts rendered rows only.
|
||||
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "周会摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": 2,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope struct {
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String())
|
||||
}
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
|
||||
func TestMinuteSearchFieldExtractors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
item := map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "<h>周会</h>摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
if got := minuteSearchToken(item); got != "minute_1" {
|
||||
t.Fatalf("minuteSearchToken() = %q, want minute_1", got)
|
||||
}
|
||||
if got := minuteSearchDisplayInfo(item); got != "<h>周会</h>摘要" {
|
||||
t.Fatalf("minuteSearchDisplayInfo() = %q", got)
|
||||
}
|
||||
if got := minuteSearchDescription(item); got != "周会纪要" {
|
||||
t.Fatalf("minuteSearchDescription() = %q, want 周会纪要", got)
|
||||
}
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
|
||||
func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
item := map[string]interface{}{
|
||||
"token": "minute_2",
|
||||
"display_info": "回退摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "回退纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/fallback",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
if got := minuteSearchToken(item); got != "minute_2" {
|
||||
t.Fatalf("minuteSearchToken() = %q, want minute_2", got)
|
||||
}
|
||||
if got := minuteSearchDescription(item); got != "回退纪要" {
|
||||
t.Fatalf("minuteSearchDescription() = %q, want 回退纪要", got)
|
||||
}
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
|
||||
func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
item := map[string]interface{}{
|
||||
"token": "minute_3",
|
||||
"display_info": "无元信息摘要",
|
||||
}
|
||||
|
||||
if got := minuteSearchToken(item); got != "minute_3" {
|
||||
t.Fatalf("minuteSearchToken() = %q, want minute_3", got)
|
||||
}
|
||||
if got := minuteSearchDescription(item); got != "" {
|
||||
t.Fatalf("minuteSearchDescription() = %q, want empty", got)
|
||||
}
|
||||
if got := minuteSearchAppLink(item); got != "" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all minutes shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesSearch,
|
||||
MinutesDownload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
"github.com/larksuite/cli/shortcuts/task"
|
||||
"github.com/larksuite/cli/shortcuts/vc"
|
||||
"github.com/larksuite/cli/shortcuts/whiteboard"
|
||||
@@ -38,6 +39,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
|
||||
81
shortcuts/sheets/sheet_add_dimension.go
Normal file
81
shortcuts/sheets/sheet_add_dimension.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetAddDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+add-dimension",
|
||||
Description: "Add rows or columns at the end of a sheet",
|
||||
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: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
length := runtime.Int("length")
|
||||
if length < 1 || length > 5000 {
|
||||
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
83
shortcuts/sheets/sheet_batch_set_style.go
Normal file
83
shortcuts/sheets/sheet_batch_set_style.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetBatchSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-set-style",
|
||||
Description: "Batch set cell styles for multiple 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: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return common.FlagErrorf("--data must be a non-empty JSON array")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
Body(map[string]interface{}{
|
||||
"data": data,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
539
shortcuts/sheets/sheet_cell_ops_test.go
Normal file
539
shortcuts/sheets/sheet_cell_ops_test.go
Normal file
@@ -0,0 +1,539 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// ── MergeCells ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetMergeCellsValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
err := SheetMergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
err := SheetMergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
|
||||
t.Fatalf("expected sheet-id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS",
|
||||
}, nil)
|
||||
if err := SheetMergeCells.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `merge_cells`) {
|
||||
t.Fatalf("DryRun URL missing merge_cells: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun range not normalized: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) {
|
||||
t.Fatalf("DryRun missing mergeType: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetMergeCells, []string{
|
||||
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "spreadsheetToken") {
|
||||
t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetMergeCells, []string{
|
||||
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UnmergeCells ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
err := SheetUnmergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `unmerge_cells`) {
|
||||
t.Fatalf("DryRun URL missing unmerge_cells: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun missing range: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
|
||||
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
|
||||
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Replace ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetReplaceValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
err := SheetReplace.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
|
||||
"range": "sheet2!A1:B2",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
err := SheetReplace.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("expected mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
|
||||
"range": "sheet1!A1:B2",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5",
|
||||
}, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `replace`) {
|
||||
t.Fatalf("DryRun URL missing replace: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"find":"old"`) {
|
||||
t.Fatalf("DryRun missing find: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"replacement":"new"`) {
|
||||
t.Fatalf("DryRun missing replacement: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"match_case":true`) {
|
||||
t.Fatalf("DryRun missing match_case: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceDryRunNoRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
|
||||
// When no range specified, range defaults to sheet-id
|
||||
if !strings.Contains(got, `"range":"sheet1"`) {
|
||||
t.Fatalf("DryRun range should default to sheet-id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"replace_result": map[string]interface{}{
|
||||
"matched_cells": []interface{}{"A1"}, "rows_count": float64(1),
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetReplace, []string{
|
||||
"+replace", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "matched_cells") {
|
||||
t.Fatalf("stdout missing matched_cells: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["find"] != "hello" || body["replacement"] != "world" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetReplace, []string{
|
||||
"+replace", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SetStyle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetSetStyleValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{invalid}`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") {
|
||||
t.Fatalf("expected JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `[{"bold":true}]`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsString(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `"bold"`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `null`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{"font":{"bold":true},"backColor":"#ff0000"}`,
|
||||
}, nil)
|
||||
if err := SheetSetStyle.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `/style`) {
|
||||
t.Fatalf("DryRun URL missing /style: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun range not normalized: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"bold":true`) {
|
||||
t.Fatalf("DryRun missing style: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "updatedCells") {
|
||||
t.Fatalf("stdout missing updatedCells: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
appendStyle, _ := body["appendStyle"].(map[string]interface{})
|
||||
if appendStyle["range"] != "sheet1!A1:B2" {
|
||||
t.Fatalf("unexpected range: %v", appendStyle["range"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── BatchSetStyle ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `not-json`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") {
|
||||
t.Fatalf("expected JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateNotArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
|
||||
t.Fatalf("expected array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `[]`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
|
||||
t.Fatalf("expected empty array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `styles_batch_update`) {
|
||||
t.Fatalf("DryRun URL missing styles_batch_update: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"totalUpdatedCells": float64(4), "revision": float64(90),
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "totalUpdatedCells") {
|
||||
t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
86
shortcuts/sheets/sheet_delete_dimension.go
Normal file
86
shortcuts/sheets/sheet_delete_dimension.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetDeleteDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-dimension",
|
||||
Description: "Delete rows or columns",
|
||||
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: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
923
shortcuts/sheets/sheet_dimension_test.go
Normal file
923
shortcuts/sheets/sheet_dimension_test.go
Normal file
@@ -0,0 +1,923 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags.
|
||||
func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range strFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range intFlags {
|
||||
cmd.Flags().Int(name, 0, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, value := range strFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range intFlags {
|
||||
if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func marshalDryRun(t *testing.T, v interface{}) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ── AddDimension ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetAddDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"length": 10}, nil)
|
||||
err := SheetAddDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, length := range []int{0, -1, 5001} {
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"length": length}, nil)
|
||||
err := SheetAddDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--length") {
|
||||
t.Fatalf("length=%d: expected length error, got: %v", length, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"length": 100}, nil)
|
||||
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionValidateWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"length": 5}, nil)
|
||||
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"length": 8}, nil)
|
||||
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"sheetId":"sheet1"`) {
|
||||
t.Fatalf("DryRun missing sheetId: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"majorDimension":"ROWS"`) {
|
||||
t.Fatalf("DryRun missing majorDimension: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"length":8`) {
|
||||
t.Fatalf("DryRun missing length: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionDryRunWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"length": 3}, nil)
|
||||
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, "shtFromURL") {
|
||||
t.Fatalf("DryRun should extract token from URL: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "Success",
|
||||
"data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetAddDimension, []string{
|
||||
"+add-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--length", "8",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"addCount"`) {
|
||||
t.Fatalf("stdout missing addCount: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
dim, _ := body["dimension"].(map[string]interface{})
|
||||
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetAddDimension, []string{
|
||||
"+add-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--length", "8",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── InsertDimension ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetInsertDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 0, "end-index": 3}, nil)
|
||||
err := SheetInsertDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
|
||||
map[string]int{"start-index": -1, "end-index": 3}, nil)
|
||||
err := SheetInsertDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 5, "end-index": 5}, nil)
|
||||
err := SheetInsertDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 0, "end-index": 4}, nil)
|
||||
if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"},
|
||||
map[string]int{"start-index": 3, "end-index": 7}, nil)
|
||||
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `insert_dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing insert_dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"startIndex":3`) {
|
||||
t.Fatalf("DryRun missing startIndex: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"endIndex":7`) {
|
||||
t.Fatalf("DryRun missing endIndex: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"inheritStyle":"BEFORE"`) {
|
||||
t.Fatalf("DryRun missing inheritStyle: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 0, "end-index": 2}, nil)
|
||||
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if strings.Contains(got, `inheritStyle`) {
|
||||
t.Fatalf("DryRun should omit inheritStyle when empty: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetInsertDimension, []string{
|
||||
"+insert-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "3",
|
||||
"--end-index", "7",
|
||||
"--inherit-style", "AFTER",
|
||||
"--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 request body: %v", err)
|
||||
}
|
||||
dim, _ := body["dimension"].(map[string]interface{})
|
||||
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected dimension: %#v", dim)
|
||||
}
|
||||
if body["inheritStyle"] != "AFTER" {
|
||||
t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetInsertDimension, []string{
|
||||
"+insert-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "COLUMNS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "2",
|
||||
"--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 request body: %v", err)
|
||||
}
|
||||
if _, ok := body["inheritStyle"]; ok {
|
||||
t.Fatalf("inheritStyle should be absent when not specified: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetInsertDimension, []string{
|
||||
"+insert-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "3",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateDimension ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Neither --visible nor --fixed-size is set (Changed returns false)
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3}, nil)
|
||||
// Register the flags but don't set them so Changed() returns false
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
rt.Cmd.Flags().Int("fixed-size", 0, "")
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") {
|
||||
t.Fatalf("expected properties error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3},
|
||||
map[string]bool{"visible": true})
|
||||
// Ensure fixed-size flag exists but is not set
|
||||
rt.Cmd.Flags().Int("fixed-size", 0, "")
|
||||
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil)
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
|
||||
t.Fatalf("expected fixed-size error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil)
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
|
||||
t.Fatalf("expected fixed-size error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil)
|
||||
// Ensure visible flag exists but is not set
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"visible":true`) {
|
||||
t.Fatalf("DryRun missing visible: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"fixedSize":50`) {
|
||||
t.Fatalf("DryRun missing fixedSize: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3},
|
||||
map[string]bool{"visible": false})
|
||||
// Add fixed-size flag but don't set it
|
||||
rt.Cmd.Flags().Int("fixed-size", 0, "")
|
||||
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `"visible":false`) {
|
||||
t.Fatalf("DryRun missing visible: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `fixedSize`) {
|
||||
t.Fatalf("DryRun should omit fixedSize when not set: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
|
||||
"+update-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "3",
|
||||
"--visible=true",
|
||||
"--fixed-size", "50",
|
||||
"--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 request body: %v", err)
|
||||
}
|
||||
props, _ := body["dimensionProperties"].(map[string]interface{})
|
||||
if props["visible"] != true {
|
||||
t.Fatalf("expected visible=true, got: %#v", props)
|
||||
}
|
||||
if props["fixedSize"] != float64(50) {
|
||||
t.Fatalf("expected fixedSize=50, got: %#v", props)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
|
||||
"+update-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "3",
|
||||
"--visible=true",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MoveDimension ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetMoveDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--destination-index") {
|
||||
t.Fatalf("expected destination-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil)
|
||||
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
|
||||
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
|
||||
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `move_dimension`) {
|
||||
t.Fatalf("DryRun URL missing move_dimension: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"major_dimension":"ROWS"`) {
|
||||
t.Fatalf("DryRun missing major_dimension: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"start_index":0`) {
|
||||
t.Fatalf("DryRun missing start_index: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"destination_index":4`) {
|
||||
t.Fatalf("DryRun missing destination_index: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionDryRunWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil)
|
||||
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, "shtFromURL") {
|
||||
t.Fatalf("DryRun should extract token from URL: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetMoveDimension, []string{
|
||||
"+move-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "1",
|
||||
"--destination-index", "4",
|
||||
"--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 request body: %v", err)
|
||||
}
|
||||
source, _ := body["source"].(map[string]interface{})
|
||||
if source["major_dimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected major_dimension: %v", source["major_dimension"])
|
||||
}
|
||||
if body["destination_index"] != float64(4) {
|
||||
t.Fatalf("unexpected destination_index: %v", body["destination_index"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionExecuteWithURL(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/move_dimension",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetMoveDimension, []string{
|
||||
"+move-dimension",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "COLUMNS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "2",
|
||||
"--destination-index", "0",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetMoveDimension, []string{
|
||||
"+move-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "1",
|
||||
"--destination-index", "4",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteDimension ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3}, nil)
|
||||
err := SheetDeleteDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 3}, nil)
|
||||
err := SheetDeleteDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 5, "end-index": 3}, nil)
|
||||
err := SheetDeleteDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 3, "end-index": 7}, nil)
|
||||
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 2}, nil)
|
||||
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 3, "end-index": 7}, nil)
|
||||
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"startIndex":3`) {
|
||||
t.Fatalf("DryRun missing startIndex: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"endIndex":7`) {
|
||||
t.Fatalf("DryRun missing endIndex: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 5}, nil)
|
||||
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, "shtFromURL") {
|
||||
t.Fatalf("DryRun should extract token from URL: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
|
||||
"+delete-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "3",
|
||||
"--end-index", "7",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"delCount"`) {
|
||||
t.Fatalf("stdout missing delCount: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
dim, _ := body["dimension"].(map[string]interface{})
|
||||
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected dimension: %#v", dim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
|
||||
"+delete-dimension",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "COLUMNS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "2",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
|
||||
"+delete-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "3",
|
||||
"--end-index", "7",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
95
shortcuts/sheets/sheet_insert_dimension.go
Normal file
95
shortcuts/sheets/sheet_insert_dimension.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetInsertDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+insert-dimension",
|
||||
Description: "Insert rows or columns at a specified position",
|
||||
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: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
|
||||
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") <= runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be greater than --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
|
||||
Body(body).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
|
||||
nil, body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
77
shortcuts/sheets/sheet_merge_cells.go
Normal file
77
shortcuts/sheets/sheet_merge_cells.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetMergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+merge-cells",
|
||||
Description: "Merge cells in a spreadsheet",
|
||||
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>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
94
shortcuts/sheets/sheet_move_dimension.go
Normal file
94
shortcuts/sheets/sheet_move_dimension.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetMoveDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+move-dimension",
|
||||
Description: "Move rows or columns to a new position",
|
||||
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: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true},
|
||||
{Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if runtime.Int("destination-index") < 0 {
|
||||
return common.FlagErrorf("--destination-index must be >= 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension").
|
||||
Body(map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"major_dimension": runtime.Str("dimension"),
|
||||
"start_index": runtime.Int("start-index"),
|
||||
"end_index": runtime.Int("end-index"),
|
||||
},
|
||||
"destination_index": runtime.Int("destination-index"),
|
||||
}).
|
||||
Set("token", token).
|
||||
Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(runtime.Str("sheet-id")),
|
||||
),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"major_dimension": runtime.Str("dimension"),
|
||||
"start_index": runtime.Int("start-index"),
|
||||
"end_index": runtime.Int("end-index"),
|
||||
},
|
||||
"destination_index": runtime.Int("destination-index"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
112
shortcuts/sheets/sheet_replace.go
Normal file
112
shortcuts/sheets/sheet_replace.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetReplace = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+replace",
|
||||
Description: "Find and replace cell values in a spreadsheet",
|
||||
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: "find", Desc: "search text or regex pattern", Required: true},
|
||||
{Name: "replacement", Desc: "replacement text", Required: true},
|
||||
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
|
||||
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
|
||||
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
|
||||
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
|
||||
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
|
||||
Body(map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", sheetID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(sheetID),
|
||||
),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
95
shortcuts/sheets/sheet_set_style.go
Normal file
95
shortcuts/sheets/sheet_set_style.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+set-style",
|
||||
Description: "Set cell style for a 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>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := style.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--style must be a JSON object, got %T", style)
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
|
||||
Body(map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
74
shortcuts/sheets/sheet_unmerge_cells.go
Normal file
74
shortcuts/sheets/sheet_unmerge_cells.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetUnmergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+unmerge-cells",
|
||||
Description: "Unmerge (split) cells in a spreadsheet",
|
||||
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>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
111
shortcuts/sheets/sheet_update_dimension.go
Normal file
111
shortcuts/sheets/sheet_update_dimension.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var SheetUpdateDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-dimension",
|
||||
Description: "Update row or column properties (visibility, size)",
|
||||
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: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "visible", Type: "bool", Desc: "true to show, false to hide"},
|
||||
{Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) 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")
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
|
||||
return common.FlagErrorf("--fixed-size must be >= 1")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
props := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("visible") {
|
||||
props["visible"] = runtime.Bool("visible")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
"dimensionProperties": props,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
props := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("visible") {
|
||||
props["visible"] = runtime.Bool("visible")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
"dimensionProperties": props,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -16,5 +16,25 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetFind,
|
||||
SheetCreate,
|
||||
SheetExport,
|
||||
SheetMergeCells,
|
||||
SheetUnmergeCells,
|
||||
SheetReplace,
|
||||
SheetSetStyle,
|
||||
SheetBatchSetStyle,
|
||||
SheetAddDimension,
|
||||
SheetInsertDimension,
|
||||
SheetUpdateDimension,
|
||||
SheetMoveDimension,
|
||||
SheetDeleteDimension,
|
||||
SheetCreateFilterView,
|
||||
SheetUpdateFilterView,
|
||||
SheetListFilterViews,
|
||||
SheetGetFilterView,
|
||||
SheetDeleteFilterView,
|
||||
SheetCreateFilterViewCondition,
|
||||
SheetUpdateFilterViewCondition,
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
}
|
||||
}
|
||||
|
||||
13
shortcuts/slides/shortcuts.go
Normal file
13
shortcuts/slides/shortcuts.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all slides shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
}
|
||||
}
|
||||
216
shortcuts/slides/slides_create.go
Normal file
216
shortcuts/slides/slides_create.go
Normal file
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPresentationWidth = 960
|
||||
defaultPresentationHeight = 540
|
||||
maxSlidesPerCreate = 10
|
||||
)
|
||||
|
||||
// SlidesCreate creates a new Lark Slides presentation with bot auto-grant.
|
||||
var SlidesCreate = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create",
|
||||
Description: "Create a Lark Slides presentation",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "presentation title"},
|
||||
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if slidesStr := runtime.Str("slides"); slidesStr != "" {
|
||||
var slides []string
|
||||
if err := json.Unmarshal([]byte(slidesStr), &slides); err != nil {
|
||||
return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings")
|
||||
}
|
||||
if len(slides) > maxSlidesPerCreate {
|
||||
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
slidesStr := runtime.Str("slides")
|
||||
createBody := map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
|
||||
if slidesStr == "" {
|
||||
dry.Desc("Create empty presentation").
|
||||
POST("/open-apis/slides_ai/v1/xml_presentations").
|
||||
Body(createBody)
|
||||
} else {
|
||||
var slides []string
|
||||
_ = json.Unmarshal([]byte(slidesStr), &slides)
|
||||
n := len(slides)
|
||||
total := n + 1
|
||||
|
||||
dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)).
|
||||
POST("/open-apis/slides_ai/v1/xml_presentations").
|
||||
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
|
||||
Body(createBody)
|
||||
|
||||
for i, slideXML := range slides {
|
||||
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
|
||||
Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": slideXML},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
content := buildPresentationXML(title)
|
||||
slidesStr := runtime.Str("slides")
|
||||
|
||||
// Step 1: Create presentation
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/slides_ai/v1/xml_presentations",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentationID := common.GetString(data, "xml_presentation_id")
|
||||
if presentationID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"title": title,
|
||||
}
|
||||
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
|
||||
result["revision_id"] = int(revisionID)
|
||||
}
|
||||
|
||||
// Step 2: Add slides if provided
|
||||
if slidesStr != "" {
|
||||
var slides []string
|
||||
_ = json.Unmarshal([]byte(slidesStr), &slides) // already validated
|
||||
|
||||
if len(slides) > 0 {
|
||||
slideURL := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
|
||||
var slideIDs []string
|
||||
for i, slideXML := range slides {
|
||||
slideData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": -1},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": slideXML},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"slide %d/%d failed: %v (presentation %s was created; %d slide(s) added before failure)",
|
||||
i+1, len(slides), err, presentationID, i)
|
||||
}
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
}
|
||||
}
|
||||
|
||||
result["slide_ids"] = slideIDs
|
||||
result["slides_added"] = len(slideIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch presentation URL via drive meta (best-effort)
|
||||
if metaData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": presentationID,
|
||||
"doc_type": "slides",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
},
|
||||
); err == nil {
|
||||
metas := common.GetSlice(metaData, "metas")
|
||||
if len(metas) > 0 {
|
||||
if meta, ok := metas[0].(map[string]interface{}); ok {
|
||||
if url := common.GetString(meta, "url"); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// effectiveTitle returns the title to use, falling back to "Untitled".
|
||||
func effectiveTitle(title string) string {
|
||||
if title == "" {
|
||||
return "Untitled"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// buildPresentationXML builds the minimal XML for a new empty presentation.
|
||||
func buildPresentationXML(title string) string {
|
||||
escapedTitle := xmlEscape(title)
|
||||
if escapedTitle == "" {
|
||||
escapedTitle = "Untitled"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="%d" height="%d"><title>%s</title></presentation>`,
|
||||
defaultPresentationWidth, defaultPresentationHeight, escapedTitle,
|
||||
)
|
||||
}
|
||||
|
||||
// xmlEscape escapes special XML characters in text content.
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
653
shortcuts/slides/slides_create_test.go
Normal file
653
shortcuts/slides/slides_create_test.go
Normal file
@@ -0,0 +1,653 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestSlidesCreateBasic verifies that slides +create returns the presentation ID, title, and URL in user mode.
|
||||
func TestSlidesCreateBasic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_abc123",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目汇报",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc123" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc123", data["xml_presentation_id"])
|
||||
}
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateBotAutoGrant verifies that bot mode grants the current user full_access on the new presentation.
|
||||
func TestSlidesCreateBotAutoGrant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_bot",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_bot", "https://example.feishu.cn/slides/pres_bot")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/pres_bot/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"member": map[string]interface{}{
|
||||
"member_id": "ou_current_user",
|
||||
"member_type": "openid",
|
||||
"perm": "full_access",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Bot PPT",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "presentation") {
|
||||
t.Fatalf("permission_grant.message = %q, want 'presentation' mention", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateBotSkippedWithoutCurrentUser verifies that permission grant is skipped when no user open_id is configured.
|
||||
func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_no_user",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_no_user", "https://example.feishu.cn/slides/pres_no_user")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "No User PPT",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled".
|
||||
func TestSlidesCreateDryRunDefaultTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Untitled") {
|
||||
t.Fatalf("dry-run should contain Untitled in XML payload, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "xml_presentations") {
|
||||
t.Fatalf("dry-run should show API path, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateDefaultTitle verifies that omitting --title outputs "Untitled" (matching the actual resource).
|
||||
func TestSlidesCreateDefaultTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_default",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["title"] != "Untitled" {
|
||||
t.Fatalf("title = %v, want Untitled", data["title"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateMissingPresentationID verifies the error when the API returns no xml_presentation_id.
|
||||
func TestSlidesCreateMissingPresentationID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Missing ID",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when xml_presentation_id is missing, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "xml_presentation_id") {
|
||||
t.Fatalf("error = %q, want mention of xml_presentation_id", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithSlides verifies that slides +create with --slides creates the presentation and adds slides.
|
||||
func TestSlidesCreateWithSlides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_with_slides",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_with_slides", "https://example.feishu.cn/slides/pres_with_slides")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"slide_id": "slide_001",
|
||||
"revision_id": 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"slide_id": "slide_002",
|
||||
"revision_id": 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"]`
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "With Slides",
|
||||
"--slides", slidesJSON,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_with_slides" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_with_slides", data["xml_presentation_id"])
|
||||
}
|
||||
slideIDs, ok := data["slide_ids"].([]interface{})
|
||||
if !ok || len(slideIDs) != 2 {
|
||||
t.Fatalf("slide_ids = %v, want 2 elements", data["slide_ids"])
|
||||
}
|
||||
if slideIDs[0] != "slide_001" || slideIDs[1] != "slide_002" {
|
||||
t.Fatalf("slide_ids = %v, want [slide_001, slide_002]", slideIDs)
|
||||
}
|
||||
if data["slides_added"] != float64(2) {
|
||||
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithSlidesPartialFailure verifies error reporting when a slide fails to create.
|
||||
func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_partial",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
// First slide succeeds
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"slide_id": "slide_ok",
|
||||
"revision_id": 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Second slide fails
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 400,
|
||||
"msg": "invalid xml",
|
||||
},
|
||||
})
|
||||
|
||||
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<bad-xml>"]`
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Partial",
|
||||
"--slides", slidesJSON,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial failure, got nil")
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if !strings.Contains(errMsg, "pres_partial") {
|
||||
t.Fatalf("error should contain presentation ID, got: %s", errMsg)
|
||||
}
|
||||
if !strings.Contains(errMsg, "slide 2/2") {
|
||||
t.Fatalf("error should indicate slide 2/2 failed, got: %s", errMsg)
|
||||
}
|
||||
if !strings.Contains(errMsg, "1 slide(s) added") {
|
||||
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithSlidesInvalidJSON verifies validation rejects non-JSON slides input.
|
||||
func TestSlidesCreateWithSlidesInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Bad JSON",
|
||||
"--slides", "not json",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--slides invalid JSON") {
|
||||
t.Fatalf("error = %q, want --slides invalid JSON mention", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithSlidesExceedsMax verifies validation rejects arrays exceeding the limit.
|
||||
func TestSlidesCreateWithSlidesExceedsMax(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Build a JSON array with 11 elements (exceeds maxSlidesPerCreate = 10)
|
||||
elems := make([]string, 11)
|
||||
for i := range elems {
|
||||
elems[i] = `"<slide/>"` //nolint:goconst
|
||||
}
|
||||
slidesJSON := "[" + strings.Join(elems, ",") + "]"
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Too Many",
|
||||
"--slides", slidesJSON,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for exceeding max, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds maximum") {
|
||||
t.Fatalf("error = %q, want 'exceeds maximum' mention", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithSlidesEmptyArray verifies that --slides '[]' behaves like no --slides.
|
||||
func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_empty_slides",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_empty_slides", "https://example.feishu.cn/slides/pres_empty_slides")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Empty Slides",
|
||||
"--slides", "[]",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_empty_slides" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_empty_slides", data["xml_presentation_id"])
|
||||
}
|
||||
if _, ok := data["slide_ids"]; ok {
|
||||
t.Fatalf("did not expect slide_ids for empty slides array")
|
||||
}
|
||||
if _, ok := data["slides_added"]; ok {
|
||||
t.Fatalf("did not expect slides_added for empty slides array")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithSlidesDryRun verifies dry-run output shows multi-step labels.
|
||||
func TestSlidesCreateWithSlidesDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"]`
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "DryRun Slides",
|
||||
"--slides", slidesJSON,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "[1/3]") {
|
||||
t.Fatalf("dry-run should contain [1/3] step label, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "[2/3]") {
|
||||
t.Fatalf("dry-run should contain [2/3] step label, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "[3/3]") {
|
||||
t.Fatalf("dry-run should contain [3/3] step label, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "xml_presentation_id") {
|
||||
t.Fatalf("dry-run should contain placeholder xml_presentation_id, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithoutSlidesUnchanged verifies existing behavior when --slides is not passed.
|
||||
func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_no_slides",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_no_slides", "https://example.feishu.cn/slides/pres_no_slides")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "No Slides",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_no_slides" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_no_slides", data["xml_presentation_id"])
|
||||
}
|
||||
if data["title"] != "No Slides" {
|
||||
t.Fatalf("title = %v, want No Slides", data["title"])
|
||||
}
|
||||
if _, ok := data["slide_ids"]; ok {
|
||||
t.Fatalf("did not expect slide_ids when --slides not passed")
|
||||
}
|
||||
if _, ok := data["slides_added"]; ok {
|
||||
t.Fatalf("did not expect slides_added when --slides not passed")
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails.
|
||||
func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_no_url",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
// batch_query returns an error — URL fetch should be silently skipped
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99999,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "No URL",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_no_url" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"])
|
||||
}
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("did not expect url when batch_query fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXmlEscape verifies that XML special characters are properly escaped.
|
||||
func TestXmlEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"hello", "hello"},
|
||||
{"a&b", "a&b"},
|
||||
{"<script>", "<script>"},
|
||||
{`"quoted"`, ""quoted""},
|
||||
{"it's", "it's"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := xmlEscape(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("xmlEscape(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slidesTestConfig returns a CliConfig for testing with the given user open ID.
|
||||
func slidesTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-slides-create-" + suffix,
|
||||
AppSecret: "secret-slides-create-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
}
|
||||
|
||||
// runSlidesCreateShortcut mounts and executes the slides +create shortcut with the given args.
|
||||
func runSlidesCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "slides"}
|
||||
SlidesCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// registerBatchQueryStub registers a drive meta batch_query mock that returns the given URL.
|
||||
func registerBatchQueryStub(reg *httpmock.Registry, token, url string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": token, "doc_type": "slides", "title": "", "url": url},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// decodeSlidesCreateEnvelope parses the JSON output and returns the data map.
|
||||
func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -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
|
||||
@@ -153,3 +156,22 @@ Drive Folder (云空间文件夹)
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet) |
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
当需要将文档权限授予**当前应用(bot)自身**时,先通过 bot info 接口获取应用的 open_id,再调用权限接口授权:
|
||||
|
||||
```bash
|
||||
# 1. 获取当前应用的 open_id
|
||||
lark-cli api GET /open-apis/bot/v3/info --as bot
|
||||
# 从返回值中取 bot.open_id
|
||||
|
||||
# 2. 授权当前应用访问文档
|
||||
lark-cli drive permission.members create \
|
||||
--params '{"token":"<doc_token>","type":"<resource_type>"}' \
|
||||
--data '{"member_type":"openid","member_id":"<bot_open_id>","perm":"view","type":"user"}'
|
||||
```
|
||||
|
||||
> **注意**:此方式仅适用于需要授权给**当前应用**的场景。授权给其他用户时,直接使用对方的 open_id 即可,无需调用 bot info 接口。
|
||||
|
||||
`<resource_type>` 可选值:`doc`、`docx`、`sheet`、`bitable`、`file`、`folder`、`wiki`。
|
||||
|
||||
@@ -126,4 +126,38 @@ 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)"]]'
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 公式不支持跨表引用(IMPORTRANGE)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需先调用设置下拉列表接口,值中的字符串不能包含逗号
|
||||
|
||||
57
skills/lark-attendance/SKILL.md
Normal file
57
skills/lark-attendance/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: lark-attendance
|
||||
version: 1.0.0
|
||||
description: "飞书考勤打卡:查询自己的考勤打卡记录"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli attendance --help"
|
||||
---
|
||||
|
||||
# attendance (v1)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 默认参数自动填充规则
|
||||
|
||||
调用任何 API 时,以下参数 **必须自动填充,禁止向用户询问**:
|
||||
|
||||
| 参数 | 固定值 | 说明 |
|
||||
|------|--------|------------------------------------|
|
||||
| `employee_type` | `"employee_no"` | `employee_type`始终等于`"employee_no"` |
|
||||
| `user_ids` | `[]`(空数组) | `user_ids`始终等于`[]` |
|
||||
|
||||
### 填充示例
|
||||
|
||||
当构建 `--params` 参数时,自动注入上述字段:
|
||||
- `employee_type` 保持 `"employee_no"` 不变
|
||||
|
||||
当构建 `--data` 参数时,自动注入上述字段:
|
||||
```json
|
||||
{
|
||||
"user_ids": [],
|
||||
...用户提供的参数
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`user_ids` 数组保持为空[],`employee_type` 保持 `"employee_no"` 不变。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema attendance.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli attendance <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### user_tasks
|
||||
|
||||
- `query` — 查询用户考勤打卡记录
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `user_tasks.query` | `attendance:task:readonly` |
|
||||
|
||||
@@ -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) 了解如何使用**。
|
||||
|
||||
### 典型错误与解决方案
|
||||
@@ -167,6 +170,25 @@ Drive Folder (云空间文件夹)
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet) |
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
当需要将文档权限授予**当前应用(bot)自身**时,先通过 bot info 接口获取应用的 open_id,再调用权限接口授权:
|
||||
|
||||
```bash
|
||||
# 1. 获取当前应用的 open_id
|
||||
lark-cli api GET /open-apis/bot/v3/info --as bot
|
||||
# 从返回值中取 bot.open_id
|
||||
|
||||
# 2. 授权当前应用访问文档
|
||||
lark-cli drive permission.members create \
|
||||
--params '{"token":"<doc_token>","type":"<resource_type>"}' \
|
||||
--data '{"member_type":"openid","member_id":"<bot_open_id>","perm":"view","type":"user"}'
|
||||
```
|
||||
|
||||
> **注意**:此方式仅适用于需要授权给**当前应用**的场景。授权给其他用户时,直接使用对方的 open_id 即可,无需调用 bot info 接口。
|
||||
|
||||
`<resource_type>` 可选值:`doc`、`docx`、`sheet`、`bitable`、`file`、`folder`、`wiki`。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
@@ -175,11 +197,13 @@ 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 |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
|
||||
## API Resources
|
||||
@@ -196,6 +220,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
- `copy` — 复制文件
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单
|
||||
- `patch` — 修改文件标题
|
||||
|
||||
### file.comments
|
||||
|
||||
@@ -246,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) -- 认证和全局参数
|
||||
79
skills/lark-drive/references/lark-drive-delete.md
Normal file
79
skills/lark-drive/references/lark-drive-delete.md
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
# drive +delete
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
删除云空间内的文件或文件夹。删除后资源会进入回收站。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除普通文件
|
||||
lark-cli drive +delete \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file \
|
||||
--yes
|
||||
|
||||
# 删除在线文档
|
||||
lark-cli drive +delete \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--yes
|
||||
|
||||
# 删除文件夹(异步操作,会自动有限轮询任务状态)
|
||||
lark-cli drive +delete \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 需要删除的文件或文件夹 token |
|
||||
| `--type` | 是 | 文件类型,可选值:`file`、`docx`、`bitable`、`doc`、`sheet`、`mindnote`、`folder`、`shortcut`、`slides` |
|
||||
| `--yes` | 是 | 确认执行高风险删除操作 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **普通文件删除**:同步操作,成功时直接返回 `deleted=true`
|
||||
- **文件夹删除**:异步操作,接口返回 `task_id`,shortcut 会先做有限轮询;如果在轮询窗口内完成,则直接返回成功结果
|
||||
- **轮询超时不是失败**:文件夹删除内置最多轮询 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>` 继续查询
|
||||
- **状态值**:`task_check` 的服务端状态通常是 `success`、`fail`、`process`
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
```bash
|
||||
# 第一步:先直接删除文件夹
|
||||
lark-cli drive +delete \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--yes
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
- 该 shortcut 仅支持云空间文件或文件夹,不支持 wiki 文档
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS 且 10000 次/天
|
||||
|
||||
## 权限要求
|
||||
|
||||
- 删除文件时,调用身份需要满足以下其一:
|
||||
- 是文件所有者,并且拥有该文件所在父文件夹的编辑权限
|
||||
- 不是文件所有者,但拥有该父文件夹的 owner 或 full access 权限
|
||||
|
||||
## 参考
|
||||
|
||||
- [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`,请根据失败信息检查应用是否具备相应的文档协作者授权能力。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关的 AI 产物(总结、待办、章节),下载妙记音视频文件。飞书妙记的 URL 格式为: http(s)://<host>/minutes/<minute-token>"
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -14,64 +14,84 @@ metadata:
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **妙记 Token(minute_token)**:妙记的唯一标识符。通常可从妙记的 URL 链接中提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的最后一段字符串 `obcnq3b9jl72l83w4f14xxxx`)。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。
|
||||
- **妙记 Token(minute\_token)**:妙记的唯一标识符,可从妙记 URL 末尾提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的 `obcnq3b9jl72l83w4f14xxxx`)。如果 URL 中包含额外参数(如 `?xxx`),应截取路径最后一段。
|
||||
|
||||
## 使用说明
|
||||
## 核心场景
|
||||
|
||||
1. **提取 Token**:
|
||||
- 只有 `minute_token` 参数是必填的。
|
||||
- 如果 URL 中包含额外参数(如 `?xxx`),请截取路径部分的最后一段作为 token。
|
||||
- 示例:从 `https://domain.feishu.cn/minutes/obc123456?project=xxx` 中提取出 `obc123456`。
|
||||
### 1. 搜索妙记
|
||||
|
||||
2. **获取妙记信息**:
|
||||
- 使用 `lark-cli schema minutes.minutes.get` 可以查看具体的返回值结构。
|
||||
- 返回的核心字段通常包含:
|
||||
- `title`:会议标题
|
||||
- `cover`:视频/音频封面 URL
|
||||
- `duration`:会议时长(毫秒)
|
||||
- `owner_id`:所有者 ID
|
||||
- `url`:妙记链接
|
||||
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`。
|
||||
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
|
||||
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
|
||||
4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`。
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 妙记内容查询
|
||||
### 2. 查看妙记基础信息
|
||||
|
||||
1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`。
|
||||
2. 如果用户给的是妙记 URL,应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`。
|
||||
3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
|
||||
|
||||
> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL)、`duration`(时长,毫秒)、`owner_id`(所有者 ID)、`url`(妙记链接)。
|
||||
|
||||
### 3. 下载妙记音视频文件
|
||||
|
||||
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
|
||||
2. `minutes +download` 只负责音视频媒体文件。
|
||||
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
|
||||
|
||||
> **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
|
||||
### 4. 获取妙记的逐字稿、总结、待办、章节
|
||||
|
||||
1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。
|
||||
2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。
|
||||
3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。
|
||||
|
||||
```bash
|
||||
# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
|
||||
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
|
||||
|
||||
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
|
||||
lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
|
||||
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
```
|
||||
本 skill 仅提供妙记**基础元信息**查询(标题、封面、时长)。如需获取纪要**内容**(逐字稿、AI 总结、待办、章节),请使用 [lark-cli vc +notes](../lark-vc/references/lark-vc-notes.md):
|
||||
|
||||
- 用户未指定需要查询妙记的哪些内容时,默认查询基础元信息和相关联的纪要产物信息。
|
||||
- 用户未明确指定查看纪要产物(逐字稿、总结、待办、章节)时,向用户展示对应产物的链接即可,不需要直接读取产物内容。
|
||||
> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
|
||||
|
||||
## 资源关系
|
||||
|
||||
```text
|
||||
Minutes (妙记) ← minute_token 标识
|
||||
├── Metadata (标题、封面、时长、owner、url) → minutes minutes get
|
||||
└── MediaFile (音频/视频文件) → minutes +download
|
||||
```
|
||||
|
||||
> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件**。
|
||||
>
|
||||
> **路由规则**:
|
||||
>
|
||||
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
|
||||
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
|
||||
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
|
||||
> - 用户说"我的妙记 / 我拥有的妙记 / 我参与的妙记"时,可将相关过滤条件映射为 `me`;`me` 表示当前用户
|
||||
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
|
||||
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
|
||||
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
|
||||
> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
|
||||
> - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
|
||||
| Shortcut | 说明 |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
|
||||
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
|
||||
|
||||
### 妙记音视频下载
|
||||
|
||||
下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
|
||||
|
||||
```bash
|
||||
# 下载音视频文件到本地
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4
|
||||
|
||||
# 仅获取下载链接
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only
|
||||
|
||||
# 批量下载
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj
|
||||
```
|
||||
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
|
||||
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
|
||||
|
||||
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -83,14 +103,14 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
|
||||
|
||||
### minutes
|
||||
|
||||
- `get` — 获取妙记信息
|
||||
- `get` — 获取妙记信息
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `minutes.get` | `minutes:minutes:readonly` |
|
||||
| `+download` | `minutes:minutes.media:export` |
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
| ------------- | ------------------------------ |
|
||||
| `+search` | `minutes:minutes.search:read` |
|
||||
| `minutes.get` | `minutes:minutes:readonly` |
|
||||
| `+download` | `minutes:minutes.media:export` |
|
||||
|
||||
<!-- AUTO-GENERATED-END -->
|
||||
|
||||
180
skills/lark-minutes/references/lark-minutes-search.md
Normal file
180
skills/lark-minutes/references/lark-minutes-search.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# minutes +search
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
搜索妙记列表,支持关键词、所有者、参与者以及时间范围等多条件过滤。所有者与参与者都支持传入多个 open\_id,也支持传入 `me` 表示当前用户。只读操作,不修改任何妙记数据。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli minutes +search`(调用 `POST /open-apis/minutes/v1/minutes/search`)。
|
||||
|
||||
## 典型触发表达
|
||||
|
||||
以下说法通常应优先使用 `minutes +search`:
|
||||
|
||||
- 我的妙记
|
||||
- 我拥有的妙记
|
||||
- 我参与的妙记
|
||||
- 最近的妙记
|
||||
- 某个关键词的妙记
|
||||
- 某段时间内的妙记
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 关键词搜索
|
||||
lark-cli minutes +search --query "预算复盘"
|
||||
|
||||
# 查询某一天内的妙记(单日查询时,建议将 start 和 end 都填写为同一天)
|
||||
lark-cli minutes +search --start 2026-03-10 --end 2026-03-10
|
||||
|
||||
# 按时间范围搜索
|
||||
lark-cli minutes +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
lark-cli minutes +search --start 2026-03-10 --end 2026-03-17
|
||||
|
||||
# 关键词 + 时间范围
|
||||
lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00"
|
||||
lark-cli minutes +search --query "预算复盘" --end "2026-03-17T00:00+08:00"
|
||||
|
||||
# 按参与者过滤(open_id,逗号分隔)
|
||||
lark-cli minutes +search --participant-ids "ou_x,ou_y"
|
||||
|
||||
# 按所有者过滤(open_id,逗号分隔)
|
||||
lark-cli minutes +search --owner-ids "ou_owner,ou_owner_2"
|
||||
|
||||
# 查询我参与的妙记
|
||||
lark-cli minutes +search --participant-ids "me"
|
||||
|
||||
# 查询我拥有的妙记
|
||||
lark-cli minutes +search --owner-ids "me"
|
||||
|
||||
# 多条件组合查询
|
||||
lark-cli minutes +search --owner-ids "ou_owner" --participant-ids "ou_x" --start "2026-03-10T00:00+08:00"
|
||||
|
||||
# 分页查询
|
||||
lark-cli minutes +search --query "预算复盘" --page-size 20
|
||||
lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PAGE_TOKEN>'
|
||||
|
||||
# 输出为结构化 JSON
|
||||
lark-cli minutes +search --query "预算复盘" --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ------------------------- | -- | ------------------------------------ |
|
||||
| `--query <text>` | 否 | 搜索关键词 |
|
||||
| `--owner-ids <ids>` | 否 | 所有者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 |
|
||||
| `--participant-ids <ids>` | 否 | 参与者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 |
|
||||
| `--start <time>` | 否 | 开始时间(ISO 8601 或仅日期) |
|
||||
| `--end <time>` | 否 | 结束时间(ISO 8601 或仅日期) |
|
||||
| `--page-size <n>` | 否 | 每页数量,默认 `15`,最大 `30` |
|
||||
| `--page-token <token>` | 否 | 下一页分页 token |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 至少提供一个过滤条件
|
||||
|
||||
所有参数均可选,但必须至少提供一个过滤条件:`--query`、`--owner-ids`、`--participant-ids`、`--start` 或 `--end`。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
|
||||
该接口仅支持 `user` 身份,使用前需完成 `lark-cli auth login` 并具备 `minutes:minutes.search:read` 权限。
|
||||
|
||||
### 3. `me` 表示当前用户
|
||||
|
||||
在 `--owner-ids` 和 `--participant-ids` 中可使用 `me`,表示当前登录用户。该值会在本地解析为当前用户的 `open_id`,无需手动先查询自己的用户 ID。
|
||||
若当前环境尚未完成用户登录,或 CLI 无法解析出当前用户的 `open_id`,则应先执行 `lark-cli auth login`,再重新执行搜索。
|
||||
|
||||
### 4. 支持分页
|
||||
|
||||
当返回 `has_more=true` 时,使用响应中的 `page_token` 配合 `--page-token` 获取下一页结果。
|
||||
|
||||
### 5. 日期型 `--end` 包含当天整天
|
||||
|
||||
当 `--end` 传入的是仅日期格式(如 `2026-03-10`)时,CLI 会将它解释为当天 `23:59:59`,而不是当天 `00:00:00`。
|
||||
CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时间戳发给 API;在 dry-run 或排查请求体时,看到的 `Z` 结尾时间表示同一个绝对时间点的 UTC 表示,不改变“按当天整天查询”的语义。
|
||||
|
||||
这意味着:
|
||||
|
||||
- `--start 2026-03-10 --end 2026-03-10` 表示只查 `2026-03-10` 当天
|
||||
- `--start 2026-03-10 --end 2026-03-11` 表示查询 `2026-03-10` 和 `2026-03-11` 两天
|
||||
|
||||
如果用户说“昨天的妙记”“今天的妙记”“某一天内的妙记”,应把 `--start` 和 `--end` 都设置为同一天,而不是把 `--end` 设成下一天。
|
||||
|
||||
### 6. 会议的妙记先定位会议
|
||||
|
||||
如果用户明确要找某场会议的妙记,或同时提到“会议 / 开会 / 会”和“妙记”,应优先使用 `vc +search` 先定位会议,再按需通过 `vc +recording` 获取 `minute_token`,不要直接按妙记时间范围或关键词搜索。
|
||||
只有在无法通过会议搜索定位目标会议,或用户明确要求按妙记维度检索时,才回退到 `minutes +search`。
|
||||
|
||||
<br />
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下时间格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
| -------------- | --------------------------- | ---------------------------------- |
|
||||
| ISO 8601(带时区) | `2026-03-10T14:00:00+08:00` | 推荐 |
|
||||
| ISO 8601(不带时区) | `2026-03-10T14:00:00` | 按本地时区解析 |
|
||||
| 仅日期 | `2026-03-10` | 按天粒度解析;若用于 `--end`,表示当天 `23:59:59` |
|
||||
|
||||
## 输出结果
|
||||
|
||||
- 默认输出包含 `items`、`total`、`has_more` 和 `page_token`。
|
||||
|
||||
## Pagination (`has_more` / `page_token`)
|
||||
|
||||
- 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。
|
||||
- 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。
|
||||
- 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more` 和 `page_token` 为准。
|
||||
- `total` 数量小于 50 时,自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否获取全部结果。
|
||||
|
||||
```bash
|
||||
# First page
|
||||
lark-cli minutes +search --query "预算复盘" --page-size 20
|
||||
|
||||
# Next page
|
||||
lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PAGE_TOKEN>'
|
||||
```
|
||||
|
||||
## 搜索结果中的下一步
|
||||
|
||||
搜索结果中的 `token` 可直接作为 `minute_token` 用于继续查询妙记产物:
|
||||
通常先用搜索结果中的 `token` 获取妙记基础信息,确认描述、链接等元数据是否命中目标;需要进一步查看内容时,再继续查询关联的纪要产物。
|
||||
|
||||
如果你已经确定目标妙记,优先直接复用搜索结果中的 `token`,避免重复搜索。
|
||||
|
||||
```bash
|
||||
# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
|
||||
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
|
||||
|
||||
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
|
||||
lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
| ---------------------- | ----------------------------------------------------- | -------------------------------------------- |
|
||||
| 命令直接报错,要求提供过滤条件 | 没有传入 `--query`、时间范围或任何过滤 ID | 至少补充一个过滤条件后重试 |
|
||||
| 时间参数校验失败 | `--start` 或 `--end` 格式不合法 | 改用 ISO 8601 或 `YYYY-MM-DD` |
|
||||
| `owner-ids` 校验失败 | 传入的不是 open\_id,且也不是 `me`;或传了 `me` 但当前用户 open\_id 不可解析 | 改为 `ou_` 开头的用户 ID,或先完成 `auth login` 后再传 `me` |
|
||||
| `participant-ids` 校验失败 | 传入的不是 open\_id,且也不是 `me`;或传了 `me` 但当前用户 open\_id 不可解析 | 改为 `ou_` 开头的用户 ID,或先完成 `auth login` 后再传 `me` |
|
||||
| 权限不足 | 未授权 `minutes:minutes.search:read` | 使用 `auth login` 完成授权 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 当用户说“我的妙记”时,优先理解为 `--owner-ids me`。
|
||||
- 当用户说“我参与的妙记”时,优先理解为 `--participant-ids me`。
|
||||
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;只有无法定位目标会议时,再回退到妙记搜索。
|
||||
- 必须使用 `--format json` 输出,你更加擅长解析 JSON 数据。
|
||||
- 排查参数与请求结构时优先使用 `--dry-run`。
|
||||
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的妙记,需要拆分为多次时间范围为一个月查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关命令
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令
|
||||
@@ -141,6 +141,40 @@ 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)"]]'
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 公式不支持跨表引用(IMPORTRANGE)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需先调用设置下拉列表接口,值中的字符串不能包含逗号
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
@@ -155,6 +189,26 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
|
||||
| [`+find`](references/lark-sheets-find.md) | Find cells in a spreadsheet |
|
||||
| [`+create`](references/lark-sheets-create.md) | Create a spreadsheet (optional header row and initial data) |
|
||||
| [`+export`](references/lark-sheets-export.md) | Export a spreadsheet (async task polling + optional download) |
|
||||
| [`+merge-cells`](references/lark-sheets-merge-cells.md) | Merge cells in a spreadsheet |
|
||||
| [`+unmerge-cells`](references/lark-sheets-unmerge-cells.md) | Unmerge (split) cells in a spreadsheet |
|
||||
| [`+replace`](references/lark-sheets-replace.md) | Find and replace cell values |
|
||||
| [`+set-style`](references/lark-sheets-set-style.md) | Set cell style for a range |
|
||||
| [`+batch-set-style`](references/lark-sheets-batch-set-style.md) | Batch set cell styles for multiple ranges |
|
||||
| [`+add-dimension`](references/lark-sheets-add-dimension.md) | Add rows or columns at the end of a sheet |
|
||||
| [`+insert-dimension`](references/lark-sheets-insert-dimension.md) | Insert rows or columns at a specified position |
|
||||
| [`+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 |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
51
skills/lark-sheets/references/lark-sheets-add-dimension.md
Normal file
51
skills/lark-sheets/references/lark-sheets-add-dimension.md
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
# sheets +add-dimension(增加行列)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +add-dimension`。
|
||||
|
||||
在工作表末尾追加空行或空列,不影响已有数据。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 在末尾追加 10 行
|
||||
lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension ROWS --length 10
|
||||
|
||||
# 在末尾追加 3 列
|
||||
lark-cli sheets +add-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension COLUMNS --length 3
|
||||
|
||||
# 仅预览参数(不发请求)
|
||||
lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension ROWS --length 5 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) |
|
||||
| `--sheet-id <id>` | 是 | 工作表 ID |
|
||||
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS` 或 `COLUMNS` |
|
||||
| `--length <n>` | 是 | 追加数量(1-5000) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含:
|
||||
|
||||
- `addCount`:实际追加的行/列数
|
||||
- `majorDimension`:`ROWS` 或 `COLUMNS`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数
|
||||
- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
53
skills/lark-sheets/references/lark-sheets-batch-set-style.md
Normal file
53
skills/lark-sheets/references/lark-sheets-batch-set-style.md
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
# sheets +batch-set-style(批量设置单元格样式)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +batch-set-style`。
|
||||
|
||||
对多个范围批量设置不同的单元格样式,一次请求可包含多组范围和样式。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 对两组范围分别设置样式
|
||||
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--data '[{"ranges":["<sheetId>!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["<sheetId>!D1:F3"],"style":{"foreColor":"#ff0000"}}]'
|
||||
|
||||
# 同一样式应用到多个范围
|
||||
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--data '[{"ranges":["<sheetId>!A1:B2","<sheetId>!D4:E5"],"style":{"hAlign":1,"font":{"bold":true}}}]'
|
||||
|
||||
# 仅预览
|
||||
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--data '[{"ranges":["<sheetId>!A1:B2"],"style":{"backColor":"#0000ff"}}]' --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) |
|
||||
| `--data <json>` | 是 | JSON 数组,每项包含 `ranges`(字符串数组)和 `style`(样式对象) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
### style 对象字段
|
||||
|
||||
与 `+set-style` 相同,参见 [lark-sheets-set-style](lark-sheets-set-style.md)。
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含:
|
||||
|
||||
- `totalUpdatedRows/totalUpdatedColumns/totalUpdatedCells`:汇总更新量
|
||||
- `revision`:工作表版本号
|
||||
- `responses[]`:每个范围的更新详情
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-set-style](lark-sheets-set-style.md) — 单范围设置样式
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -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) — 添加筛选条件
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
# sheets +delete-dimension(删除行列)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-dimension`。
|
||||
|
||||
删除指定范围的行或列,已有数据向上或向左移动。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**破坏性写入操作** —— 删除后数据不可恢复。执行前必须确认用户意图,建议先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除第 3-7 行(1-indexed,闭区间)
|
||||
lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7
|
||||
|
||||
# 删除第 5-8 列
|
||||
lark-cli sheets +delete-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension COLUMNS --start-index 5 --end-index 8
|
||||
|
||||
# 仅预览参数
|
||||
lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) |
|
||||
| `--sheet-id <id>` | 是 | 工作表 ID |
|
||||
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS` 或 `COLUMNS` |
|
||||
| `--start-index <n>` | 是 | 起始位置(**1-indexed**,含) |
|
||||
| `--end-index <n>` | 是 | 结束位置(**1-indexed**,含) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含:
|
||||
|
||||
- `delCount`:实际删除的行/列数
|
||||
- `majorDimension`:`ROWS` 或 `COLUMNS`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 增加行列
|
||||
- [lark-sheets-insert-dimension](lark-sheets-insert-dimension.md) — 插入行列
|
||||
- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数
|
||||
- [lark-shared](../../lark-shared/SKILL.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 |
|
||||
@@ -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,51 @@
|
||||
|
||||
# sheets +insert-dimension(插入行列)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +insert-dimension`。
|
||||
|
||||
在指定位置插入空行或空列,已有数据向下或向右移动。支持继承相邻行/列样式。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 在第 3 行前插入 4 行空行(0-indexed,插入位置 3~7,不含 7)
|
||||
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7
|
||||
|
||||
# 插入列,并继承前方列的样式
|
||||
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension COLUMNS --start-index 2 --end-index 4 \
|
||||
--inherit-style BEFORE
|
||||
|
||||
# 仅预览参数
|
||||
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension ROWS --start-index 0 --end-index 2 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url <url>` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token <token>` | 否 | 表格 token(与 `--url` 二选一) |
|
||||
| `--sheet-id <id>` | 是 | 工作表 ID |
|
||||
| `--dimension <ROWS\|COLUMNS>` | 是 | 操作维度:`ROWS` 或 `COLUMNS` |
|
||||
| `--start-index <n>` | 是 | 起始位置(0-indexed) |
|
||||
| `--end-index <n>` | 是 | 结束位置(0-indexed,不包含;插入数量 = end - start) |
|
||||
| `--inherit-style <BEFORE\|AFTER>` | 否 | 样式继承方向:`BEFORE` 继承前方、`AFTER` 继承后方;不传则为空白样式 |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON(成功时 `data` 为空对象 `{}`)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 在末尾追加行列
|
||||
- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -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)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user