mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
686c91dc71 | ||
|
|
cfd89e0e28 | ||
|
|
ac4c34f2ad | ||
|
|
3ed691b25c | ||
|
|
30ad38d4b6 | ||
|
|
4fab062219 | ||
|
|
f27b8fdf40 | ||
|
|
c100ca049e | ||
|
|
4d68e09537 | ||
|
|
a3bbe00ee0 | ||
|
|
0250054a90 | ||
|
|
d7ee5b5769 | ||
|
|
b37adfd0ee | ||
|
|
082275f32b | ||
|
|
2eb9fae575 | ||
|
|
418192507e | ||
|
|
7752afab96 | ||
|
|
f7a56f38b1 | ||
|
|
ea056d132e | ||
|
|
7fc963f455 | ||
|
|
520acb618c | ||
|
|
dce2beb91c | ||
|
|
97968b6ef2 | ||
|
|
6bb988a655 | ||
|
|
4422265d5f |
42
CHANGELOG.md
42
CHANGELOG.md
@@ -2,6 +2,46 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.23] - 2026-04-30
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
|
||||
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
|
||||
- **drive**: Add `+status` shortcut for content-hash diff (#692)
|
||||
- **drive**: Support `--file-name` for drive export (#685)
|
||||
- **base**: Add markdown output for record reads (#726)
|
||||
- **minutes**: Add media upload shortcut (#725)
|
||||
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
|
||||
- **cmdutil**: Support `@file` for params and data (#724)
|
||||
- Add markdown shortcuts and skill docs (#704)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide lark-doc v2 usage (#710)
|
||||
- **minutes**: Clarify minutes file-to-notes routing (#732)
|
||||
|
||||
## [v1.0.22] - 2026-04-29
|
||||
|
||||
### Features
|
||||
|
||||
- **task**: Add resource agent & `agent_task_step_info` (#693)
|
||||
- **task**: Support app task members by id (#712)
|
||||
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
|
||||
- **slides**: Add slide templates with template-first skill guidance (#684)
|
||||
- **mail**: Support calendar events in emails (#646)
|
||||
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Make Windows zip extraction resilient (#713)
|
||||
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Clarify base search routing (#708)
|
||||
- **base**: Align base skills and view config contracts (#653)
|
||||
|
||||
## [v1.0.21] - 2026-04-28
|
||||
|
||||
### Features
|
||||
@@ -539,6 +579,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
|
||||
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 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, Markdown, and more, with 200+ commands and 24 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** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 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
|
||||
@@ -28,6 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📊 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 |
|
||||
@@ -139,6 +140,7 @@ lark-cli auth status
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-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 |
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -28,6 +28,7 @@
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -140,6 +141,7 @@ lark-cli auth status
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
|
||||
@@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
@@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
@@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
// Parse --data as JSON map for form fields (not as body).
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
@@ -122,5 +122,5 @@ func getLoginMsg(lang string) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs"}
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
@@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
|
||||
@@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
// Parse --data as form fields.
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
@@ -7,19 +7,20 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ParseOptionalBody parses --data JSON for methods that accept a request body.
|
||||
// Supports stdin (-) and single-quote stripping via ResolveInput.
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
// Returns (nil, nil) if the method has no body or data is empty.
|
||||
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
|
||||
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
resolved, err := ResolveInput(data, stdin)
|
||||
resolved, err := ResolveInput(data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--data: %s", err)
|
||||
}
|
||||
@@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
|
||||
}
|
||||
|
||||
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
|
||||
// Supports stdin (-) and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin)
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin, fileIO)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("%s: %s", label, err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseOptionalBody(tt.method, tt.data, nil)
|
||||
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseJSONMap(tt.input, tt.label, nil)
|
||||
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -4,19 +4,27 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// ResolveInput resolves special input conventions for a raw flag value:
|
||||
// - "-" → read all bytes from stdin
|
||||
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
|
||||
// - other → return as-is
|
||||
// - "-" → read all bytes from stdin
|
||||
// - "@<path>" → read all bytes from the file at <path> via fileIO
|
||||
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
|
||||
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
|
||||
// - other → return as-is
|
||||
//
|
||||
// This allows callers to bypass shell quoting issues (especially on Windows
|
||||
// PowerShell) by piping JSON via stdin instead of command-line arguments.
|
||||
func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
// fileIO is required for "@<path>" inputs and goes through path validation
|
||||
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
|
||||
//
|
||||
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
|
||||
// by reading JSON from a file (@path) or piping via stdin (-).
|
||||
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -37,6 +45,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// escape: @@... → literal @... (no file read)
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
return raw[1:], nil
|
||||
}
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("file path cannot be empty after @")
|
||||
}
|
||||
data, err := ReadInputFile(fileIO, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := strings.TrimSpace(string(data))
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("file %q is empty", path)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// strip surrounding single quotes (Windows cmd.exe passes them literally)
|
||||
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
|
||||
raw = raw[1 : len(raw)-1]
|
||||
@@ -44,3 +74,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
}
|
||||
f, err := fileIO.Open(path)
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func wrapInputFileError(path string, err error) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return fmt.Errorf("invalid file path %q: %w", path, err)
|
||||
}
|
||||
return fmt.Errorf("cannot read file %q: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func TestResolveInput_Stdin(t *testing.T) {
|
||||
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
|
||||
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -20,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
|
||||
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
|
||||
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -30,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_Empty(t *testing.T) {
|
||||
_, err := ResolveInput("-", strings.NewReader(""))
|
||||
_, err := ResolveInput("-", strings.NewReader(""), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty stdin")
|
||||
}
|
||||
@@ -44,21 +47,21 @@ type errorReader struct{}
|
||||
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
|
||||
|
||||
func TestResolveInput_Stdin_ReadError(t *testing.T) {
|
||||
_, err := ResolveInput("-", errorReader{})
|
||||
_, err := ResolveInput("-", errorReader{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
|
||||
t.Errorf("expected read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
|
||||
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
|
||||
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for whitespace-only stdin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_Nil(t *testing.T) {
|
||||
_, err := ResolveInput("-", nil)
|
||||
_, err := ResolveInput("-", nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil stdin")
|
||||
}
|
||||
@@ -77,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResolveInput(tt.in, nil)
|
||||
got, err := ResolveInput(tt.in, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -89,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Empty(t *testing.T) {
|
||||
got, err := ResolveInput("", nil)
|
||||
got, err := ResolveInput("", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -99,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_PlainValue(t *testing.T) {
|
||||
got, err := ResolveInput(`{"already":"valid"}`, nil)
|
||||
got, err := ResolveInput(`{"already":"valid"}`, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -108,21 +111,103 @@ func TestResolveInput_PlainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
|
||||
// Without @file support, @-prefixed values are passed as-is
|
||||
got, err := ResolveInput("@something", nil)
|
||||
func TestResolveInput_AtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveInput("@params.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "@something" {
|
||||
t.Errorf("got %q, want %q", got, "@something")
|
||||
if got != `{"folder_token":"abc123"}` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveInput("@p.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != `{"k":"v"}` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_NotFound(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
_, err := ResolveInput("@missing.json", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("expected read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
// Absolute paths are rejected by SafeInputPath; the error must surface
|
||||
// as an invalid-path message, not a generic read failure.
|
||||
_, err := ResolveInput("@/etc/passwd", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
|
||||
t.Errorf("expected path-validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
_, err := ResolveInput("@", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("expected empty-path error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := ResolveInput("@empty.json", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "is empty") {
|
||||
t.Errorf("expected empty-file error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
|
||||
// When fileIO is nil, @path must error rather than silently fall back.
|
||||
_, err := ResolveInput("@params.json", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("expected unavailable error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_DoubleAtEscape(t *testing.T) {
|
||||
got, err := ResolveInput("@@literal", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "@literal" {
|
||||
t.Errorf("got %q, want %q", got, "@literal")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration: ResolveInput flows through ParseJSONMap correctly.
|
||||
func TestParseJSONMap_WithStdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
|
||||
got, err := ParseJSONMap("-", "--params", stdin)
|
||||
got, err := ParseJSONMap("-", "--params", stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -131,8 +216,48 @@ func TestParseJSONMap_WithStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Integration: @file flows through ParseJSONMap correctly.
|
||||
func TestParseJSONMap_WithAtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %d keys, want 2", len(got))
|
||||
}
|
||||
if got["folder_token"] != "abc123" {
|
||||
t.Errorf("got %v, want folder_token=abc123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_WithAtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
if m["text"] != "hello" {
|
||||
t.Errorf("got %v, want text=hello", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
|
||||
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
|
||||
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -143,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
|
||||
|
||||
func TestParseOptionalBody_WithStdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"text":"hello"}`)
|
||||
got, err := ParseOptionalBody("POST", "-", stdin)
|
||||
got, err := ParseOptionalBody("POST", "-", stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -176,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseJSONMap(tt.input, "--params", nil)
|
||||
got, err := ParseJSONMap(tt.input, "--params", nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -25,10 +25,26 @@ type Stub struct {
|
||||
Headers http.Header // optional full response headers (takes precedence over ContentType)
|
||||
matched bool
|
||||
|
||||
// BodyFilter (optional): match only when the captured request body satisfies
|
||||
// this predicate. Used to disambiguate multiple stubs that share a URL.
|
||||
BodyFilter func([]byte) bool
|
||||
|
||||
// OnMatch (optional): runs synchronously after the stub matches but before
|
||||
// the response is composed. Used in tests to inject panics or count
|
||||
// in-flight goroutines.
|
||||
OnMatch func(req *http.Request)
|
||||
|
||||
// Reusable (optional): when true, the stub stays available for further
|
||||
// matches after the first hit. Each match appends to CapturedBodies.
|
||||
Reusable bool
|
||||
|
||||
// CapturedHeaders records the request headers of the matched request.
|
||||
// Populated after RoundTrip matches this stub.
|
||||
CapturedHeaders http.Header
|
||||
CapturedBody []byte
|
||||
// CapturedBodies records every captured request body when Reusable is set.
|
||||
// (CapturedBody continues to record the most recent capture for back-compat.)
|
||||
CapturedBodies [][]byte
|
||||
}
|
||||
|
||||
// Registry records stubs and implements http.RoundTripper.
|
||||
@@ -51,8 +67,43 @@ func (r *Registry) Register(s *Stub) {
|
||||
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
urlStr := req.URL.String()
|
||||
|
||||
// Read body once up-front so BodyFilter can inspect it without consuming
|
||||
// the original reader; restore for downstream consumers afterwards.
|
||||
// http.RoundTripper requires us to close the original body.
|
||||
var capturedBody []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
capturedBody, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: read request body: %w", err)
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
||||
}
|
||||
|
||||
matched := r.match(req, urlStr, capturedBody)
|
||||
|
||||
if matched != nil {
|
||||
// Restore body again in case OnMatch wants to read it.
|
||||
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
||||
if matched.OnMatch != nil {
|
||||
matched.OnMatch(req)
|
||||
}
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
|
||||
}
|
||||
|
||||
// match selects the first stub whose Method/URL/BodyFilter all match the
|
||||
// request, mutates its capture state, and returns it. defer-Unlock guarantees
|
||||
// a panicking user-supplied BodyFilter cannot leak the mutex.
|
||||
func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub {
|
||||
r.mu.Lock()
|
||||
var matched *Stub
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.stubs {
|
||||
if s.matched {
|
||||
continue
|
||||
@@ -63,25 +114,18 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
|
||||
continue
|
||||
}
|
||||
s.matched = true
|
||||
if s.BodyFilter != nil && !s.BodyFilter(capturedBody) {
|
||||
continue
|
||||
}
|
||||
if !s.Reusable {
|
||||
s.matched = true
|
||||
}
|
||||
s.CapturedHeaders = req.Header.Clone()
|
||||
if req.Body != nil {
|
||||
s.CapturedBody, _ = io.ReadAll(req.Body)
|
||||
req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody))
|
||||
}
|
||||
matched = s
|
||||
break
|
||||
s.CapturedBody = capturedBody
|
||||
s.CapturedBodies = append(s.CapturedBodies, capturedBody)
|
||||
return s
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if matched != nil {
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify asserts all stubs were matched.
|
||||
@@ -90,9 +134,14 @@ func (r *Registry) Verify(t testing.TB) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.stubs {
|
||||
if !s.matched {
|
||||
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
|
||||
if s.matched {
|
||||
continue
|
||||
}
|
||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||
continue
|
||||
}
|
||||
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
var knownArrayFields = []string{
|
||||
"items", "files", "events", "rooms", "records", "nodes",
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
"users",
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" },
|
||||
"zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" }
|
||||
},
|
||||
"markdown": {
|
||||
"en": { "title": "Markdown", "description": "Drive-native Markdown file create, fetch, and overwrite" },
|
||||
"zh": { "title": "Markdown", "description": "Drive 原生 Markdown 文件的创建、读取和覆盖更新" }
|
||||
},
|
||||
"minutes": {
|
||||
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
|
||||
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.23",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -10,15 +10,16 @@ const crypto = require("crypto");
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
|
||||
// Allowlist gates the *initial* request URL only. curl --location follows
|
||||
// redirects (capped by --max-redirs 3) without re-checking the target host.
|
||||
// This is acceptable because checksum verification is the primary integrity
|
||||
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
|
||||
const ALLOWED_HOSTS = [
|
||||
const ALLOWED_HOSTS = new Set([
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"registry.npmmirror.com",
|
||||
];
|
||||
]);
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
darwin: "darwin",
|
||||
@@ -38,18 +39,77 @@ const isWindows = process.platform === "win32";
|
||||
const ext = isWindows ? ".zip" : ".tar.gz";
|
||||
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
|
||||
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
||||
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
|
||||
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
|
||||
// Build the ordered list of binary mirror URLs to try. Resolution rules:
|
||||
// 1. npm_config_registry — when the user has set a non-default
|
||||
// registry (npmmirror clone, corp Verdaccio,
|
||||
// Artifactory, …), include the derived path
|
||||
// first. Many of these proxies don't actually
|
||||
// host /-/binary/<pkg>/..., so we ALWAYS
|
||||
// append the public npmmirror as a final
|
||||
// fallback so the install does not regress
|
||||
// from the previous behavior of "GitHub →
|
||||
// npmmirror".
|
||||
// 2. registry.npmmirror.com — public China mirror, always tried last.
|
||||
// The default public npmjs registry is skipped in step 1 because it does not
|
||||
// host binaries under /-/binary/...
|
||||
//
|
||||
// Non-https / malformed npm_config_registry is silently ignored so npm users
|
||||
// with http-only internal registries don't have their installs broken.
|
||||
function resolveMirrorUrls(env, archive, version) {
|
||||
const binaryPath = `/-/binary/lark-cli/v${version}/${archive}`;
|
||||
const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath);
|
||||
|
||||
const urls = [];
|
||||
const registry = (env.npm_config_registry || "").trim();
|
||||
if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) {
|
||||
const base = new URL(registry);
|
||||
urls.push(joinUrl(base.origin + base.pathname, binaryPath));
|
||||
}
|
||||
if (!urls.includes(defaultUrl)) urls.push(defaultUrl);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function joinUrl(base, suffix) {
|
||||
return base.replace(/\/+$/, "") + suffix;
|
||||
}
|
||||
|
||||
function isValidDownloadBase(raw) {
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.protocol === "https:" && !!parsed.hostname;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDefaultNpmjsRegistry(url) {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === "registry.npmjs.org";
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedHost(url) {
|
||||
const { hostname } = new URL(url);
|
||||
if (!ALLOWED_HOSTS.includes(hostname)) {
|
||||
if (!ALLOWED_HOSTS.has(hostname)) {
|
||||
throw new Error(`Download host not allowed: ${hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the mirror URL chain and admit each host. Called from install() so
|
||||
// derived hosts only become trusted when actually needed.
|
||||
function getMirrorUrls(env) {
|
||||
const urls = resolveMirrorUrls(env, archiveName, VERSION);
|
||||
for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function download(url, destPath) {
|
||||
assertAllowedHost(url);
|
||||
const args = [
|
||||
@@ -65,27 +125,69 @@ function download(url, destPath) {
|
||||
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
}
|
||||
|
||||
function extractZipWindows(archivePath, destDir) {
|
||||
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
|
||||
const psStdio = ["ignore", "inherit", "inherit"];
|
||||
const psEnv = {
|
||||
...process.env,
|
||||
LARK_CLI_ARCHIVE: archivePath,
|
||||
LARK_CLI_DEST: destDir,
|
||||
};
|
||||
|
||||
try {
|
||||
const dotnet =
|
||||
"$ErrorActionPreference='Stop';" +
|
||||
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
|
||||
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:LARK_CLI_ARCHIVE,$env:LARK_CLI_DEST)";
|
||||
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
|
||||
} catch (primaryErr) {
|
||||
try {
|
||||
const cmdlet =
|
||||
"$ErrorActionPreference='Stop';" +
|
||||
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
|
||||
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
|
||||
} catch (fallbackErr) {
|
||||
throw new Error(
|
||||
`Failed to extract ${archivePath}. ` +
|
||||
`.NET ZipFile attempt: ${primaryErr.message}. ` +
|
||||
`Expand-Archive fallback: ${fallbackErr.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function install() {
|
||||
const mirrorUrls = getMirrorUrls(process.env);
|
||||
const downloadUrls = [GITHUB_URL, ...mirrorUrls];
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
|
||||
const archivePath = path.join(tmpDir, archiveName);
|
||||
|
||||
try {
|
||||
try {
|
||||
download(GITHUB_URL, archivePath);
|
||||
} catch (err) {
|
||||
download(MIRROR_URL, archivePath);
|
||||
// Walk the chain in order; stop at the first success. Default chain:
|
||||
// GitHub → derived(npm_config_registry)? → npmmirror. The npmmirror
|
||||
// tail preserves the pre-PR safety net when a corporate proxy doesn't
|
||||
// actually host /-/binary/<pkg>/...
|
||||
let lastErr;
|
||||
let downloaded = false;
|
||||
for (const url of downloadUrls) {
|
||||
try {
|
||||
download(url, archivePath);
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
if (!downloaded) throw lastErr;
|
||||
|
||||
const expectedHash = getExpectedChecksum(archiveName);
|
||||
verifyChecksum(archivePath, expectedHash);
|
||||
|
||||
if (isWindows) {
|
||||
execFileSync("powershell", [
|
||||
"-Command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
|
||||
], { stdio: "ignore" });
|
||||
extractZipWindows(archivePath, tmpDir);
|
||||
} else {
|
||||
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
|
||||
stdio: "ignore",
|
||||
@@ -176,12 +278,15 @@ if (require.main === module) {
|
||||
} catch (err) {
|
||||
console.error(`Failed to install ${NAME}:`, err.message);
|
||||
console.error(
|
||||
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
|
||||
`\nIf you are behind a firewall or in a restricted network, try one of:\n` +
|
||||
` # 1. Use a proxy:\n` +
|
||||
` export https_proxy=http://your-proxy:port\n` +
|
||||
` npm install -g @larksuite/cli`
|
||||
` npm install -g @larksuite/cli\n\n` +
|
||||
` # 2. Point to a corporate npm mirror that proxies /-/binary/lark-cli/...:\n` +
|
||||
` npm install -g @larksuite/cli --registry=https://your-corp-mirror/`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost };
|
||||
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };
|
||||
|
||||
@@ -9,7 +9,7 @@ const os = require("os");
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const { getExpectedChecksum, verifyChecksum, assertAllowedHost } = require("./install.js");
|
||||
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
|
||||
|
||||
describe("getExpectedChecksum", () => {
|
||||
function makeTmpChecksums(content) {
|
||||
@@ -164,3 +164,117 @@ describe("assertAllowedHost", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMirrorUrls", () => {
|
||||
const ARCHIVE = "lark-cli-1.0.0-linux-amd64.tar.gz";
|
||||
const VERSION = "1.0.0";
|
||||
const DEFAULT = "https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz";
|
||||
|
||||
it("returns only the default mirror when no env vars are set", () => {
|
||||
assert.deepEqual(resolveMirrorUrls({}, ARCHIVE, VERSION), [DEFAULT]);
|
||||
});
|
||||
|
||||
it("does not derive from the default npmjs registry", () => {
|
||||
// The public npmjs registry doesn't host /-/binary/<pkg>/..., so we must
|
||||
// not point downloads at it.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://registry.npmjs.org/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("derives from non-default npm_config_registry AND keeps default as fallback", () => {
|
||||
// Critical: a corporate npm proxy (Verdaccio/Artifactory/Nexus) often
|
||||
// doesn't actually serve /-/binary/<pkg>/..., so we must keep the
|
||||
// public npmmirror as a final fallback or installs regress vs. the
|
||||
// pre-PR "GitHub → npmmirror" behavior.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://corp.example.com/repository/npm-public/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[
|
||||
"https://corp.example.com/repository/npm-public/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
|
||||
DEFAULT,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("derived URL appears before the default in the chain", () => {
|
||||
const urls = resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://corp.example.com/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
);
|
||||
assert.equal(urls.length, 2);
|
||||
assert.match(urls[0], /^https:\/\/corp\.example\.com\//);
|
||||
assert.equal(urls[1], DEFAULT);
|
||||
});
|
||||
|
||||
it("does not duplicate the default if the registry already points at it", () => {
|
||||
// If npm_config_registry happens to be the public npmmirror, we still
|
||||
// want a single entry, not two identical ones.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://registry.npmmirror.com/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("strips trailing slashes from the registry URL", () => {
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://corp.example.com///" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[
|
||||
"https://corp.example.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
|
||||
DEFAULT,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores empty/whitespace npm_config_registry", () => {
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("silently falls back when npm_config_registry is non-https", () => {
|
||||
// Implicit feature: don't break installs whose npm registry is plain http.
|
||||
// The user didn't opt into binary-mirror behavior, so just use the default.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "http://internal.example.com/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("silently falls back when npm_config_registry is file://", () => {
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "file:///tmp" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -827,28 +827,6 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Run("list", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name", "Age"},
|
||||
"record_ids": []interface{}{"rec_1"},
|
||||
"rows": []interface{}{[]interface{}{"Alice", 18}},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list with fields and view", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -864,7 +842,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
@@ -887,7 +865,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
|
||||
@@ -895,7 +873,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list new shape", func(t *testing.T) {
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -904,13 +882,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
||||
"record_id_list": []interface{}{"rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Bob", 20}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
|
||||
@@ -918,6 +897,47 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{
|
||||
[]interface{}{"Alice", 18},
|
||||
[]interface{}{"Bob", 20},
|
||||
},
|
||||
"has_more": false,
|
||||
"query_context": map[string]interface{}{
|
||||
"record_scope": "all_records",
|
||||
"field_scope": "selected_fields",
|
||||
},
|
||||
"ignored_fields": []interface{}{"Formula"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"`_record_id` is metadata for record operations, not a table field.",
|
||||
"| _record_id | Name | Age |",
|
||||
"| rec_1 | Alice | 18 |",
|
||||
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
|
||||
"Ignored fields: Formula",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
@@ -948,6 +968,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
@@ -968,6 +989,53 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Owner"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_owner"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
|
||||
"has_more": false,
|
||||
"query_context": map[string]interface{}{
|
||||
"record_scope": "view_filtered_records",
|
||||
"field_scope": "selected_fields",
|
||||
"search_scope": "fld_title(Title)",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"| _record_id | Title | Owner |",
|
||||
"| rec_1 | Created by AI | Alice |",
|
||||
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
@@ -1674,7 +1742,7 @@ func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
|
||||
}, "total": 201},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {
|
||||
|
||||
@@ -18,7 +18,7 @@ var BaseFormDelete = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseFormGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ var BaseFormQuestionsList = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -210,6 +210,102 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "record list",
|
||||
shortcut: BaseRecordList,
|
||||
wantHelp: []string{
|
||||
"field ID or name to include; repeat to project only needed fields",
|
||||
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
|
||||
"pagination size, range 1-200",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Default output is markdown",
|
||||
"Use --field-id repeatedly to keep output small",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
"requires keyword/search_fields",
|
||||
"optional select_fields/view_id/offset/limit",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record get",
|
||||
shortcut: BaseRecordGet,
|
||||
wantHelp: []string{
|
||||
"record ID",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"record_id is already known",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
assertHelpOrder(t, help, "base token", "output format")
|
||||
assertHelpOrder(t, help, "table ID", "output format")
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOrder(t *testing.T, help string, before string, after string) {
|
||||
t.Helper()
|
||||
beforeIndex := strings.Index(help, before)
|
||||
afterIndex := strings.Index(help, after)
|
||||
if beforeIndex < 0 || afterIndex < 0 {
|
||||
return
|
||||
}
|
||||
if beforeIndex > afterIndex {
|
||||
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
|
||||
10
shortcuts/base/help.go
Normal file
10
shortcuts/base/help.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func preserveFlagOrder(cmd *cobra.Command) {
|
||||
cmd.Flags().SortFlags = false
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordGet = common.Shortcut{
|
||||
@@ -21,7 +22,15 @@ var BaseRecordGet = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing.",
|
||||
},
|
||||
DryRun: dryRunRecordGet,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordGet(runtime)
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordList = common.Shortcut{
|
||||
@@ -19,13 +20,46 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
|
||||
{Name: "view-id", Desc: "view ID"},
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
|
||||
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordList(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func recordListFieldRefFlag() common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = "field ID or name to include; repeat to project only needed fields"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordReadFormatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "format",
|
||||
Default: "markdown",
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
237
shortcuts/base/record_markdown.go
Normal file
237
shortcuts/base/record_markdown.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordMarkdownIgnoredFields = 20
|
||||
|
||||
func validateRecordReadFormat(runtime *common.RuntimeContext) error {
|
||||
switch runtime.Str("format") {
|
||||
case "", "json", "markdown":
|
||||
return nil
|
||||
default:
|
||||
return output.ErrValidation("--format must be json or markdown")
|
||||
}
|
||||
}
|
||||
|
||||
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
if runtime.JqExpr != "" {
|
||||
if !runtime.Changed("format") {
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
|
||||
}
|
||||
rendered, err := renderRecordMarkdown(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
||||
}
|
||||
fmt.Fprint(runtime.IO().Out, rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
||||
fields := stringSliceValue(data["fields"])
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
|
||||
|
||||
columns := append([]string{"_record_id"}, fields...)
|
||||
writeMarkdownRow(&b, columns)
|
||||
writeMarkdownSeparator(&b, len(columns))
|
||||
for i, rowValue := range rows {
|
||||
rowItems, _ := rowValue.([]interface{})
|
||||
cells := make([]string, 0, len(columns))
|
||||
if i < len(recordIDs) {
|
||||
cells = append(cells, recordIDs[i])
|
||||
} else {
|
||||
cells = append(cells, "")
|
||||
}
|
||||
for j := range fields {
|
||||
if j < len(rowItems) {
|
||||
cells = append(cells, markdownCell(rowItems[j]))
|
||||
} else {
|
||||
cells = append(cells, "")
|
||||
}
|
||||
}
|
||||
writeMarkdownRow(&b, cells)
|
||||
}
|
||||
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
|
||||
b.WriteString("Ignored fields: ")
|
||||
b.WriteString(ignored)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func recordMarkdownMeta(data map[string]interface{}) []string {
|
||||
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
|
||||
if hasMore, ok := data["has_more"]; ok {
|
||||
meta = append(meta, "has_more="+markdownInlineValue(hasMore))
|
||||
}
|
||||
if queryContext, ok := data["query_context"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"record_scope", "field_scope", "search_scope"} {
|
||||
if value, ok := queryContext[key]; ok {
|
||||
meta = append(meta, key+"="+markdownInlineValue(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
|
||||
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func ignoredFieldsCount(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
return len(v)
|
||||
case []string:
|
||||
return len(v)
|
||||
case nil:
|
||||
return 0
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func ignoredFieldsMarkdown(value interface{}) string {
|
||||
items := markdownListItems(value)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
total := len(items)
|
||||
if len(items) > maxRecordMarkdownIgnoredFields {
|
||||
items = items[:maxRecordMarkdownIgnoredFields]
|
||||
items = append(items, fmt.Sprintf("...(%d total)", total))
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
func markdownListItems(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, markdownInlineValue(item))
|
||||
}
|
||||
return items
|
||||
case []string:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, markdownInlineValue(item))
|
||||
}
|
||||
return items
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return []string{markdownInlineValue(v)}
|
||||
}
|
||||
}
|
||||
|
||||
func writeMarkdownRow(b *strings.Builder, cells []string) {
|
||||
b.WriteString("| ")
|
||||
for i, cell := range cells {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
b.WriteString(markdownTableText(cell))
|
||||
}
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func writeMarkdownSeparator(b *strings.Builder, columns int) {
|
||||
b.WriteString("| ")
|
||||
for i := 0; i < columns; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
b.WriteString("---")
|
||||
}
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func markdownCell(value interface{}) string {
|
||||
return markdownInlineValue(value)
|
||||
}
|
||||
|
||||
func markdownInlineValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func markdownTableText(value string) string {
|
||||
value = strings.ReplaceAll(value, "\\", "\\\\")
|
||||
value = strings.ReplaceAll(value, "|", "\\|")
|
||||
value = strings.ReplaceAll(value, "\r\n", "<br>")
|
||||
value = strings.ReplaceAll(value, "\n", "<br>")
|
||||
return value
|
||||
}
|
||||
|
||||
func stringSliceValue(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string(nil), v...)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
229
shortcuts/base/record_markdown_test.go
Normal file
229
shortcuts/base/record_markdown_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type recordMarkdownCSTestProvider struct {
|
||||
alert *extcs.Alert
|
||||
}
|
||||
|
||||
func (p *recordMarkdownCSTestProvider) Name() string { return "test" }
|
||||
func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
|
||||
parentCmd := &cobra.Command{Use: "lark-cli"}
|
||||
baseCmd := &cobra.Command{Use: "base"}
|
||||
cmd := &cobra.Command{Use: "+record-list"}
|
||||
cmd.Flags().String("format", "markdown", "")
|
||||
parentCmd.AddCommand(baseCmd)
|
||||
baseCmd.AddCommand(cmd)
|
||||
return &common.RuntimeContext{
|
||||
Config: &core.CliConfig{Brand: core.BrandFeishu},
|
||||
Cmd: cmd,
|
||||
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownEmptyResult(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{},
|
||||
"data": []interface{}{},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name | Age |",
|
||||
"Meta: count=0; has_more=false",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name|Label", "Note"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name\\|Label | Note |",
|
||||
"| rec_1 | A\\|B | line1<br>line2 |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
|
||||
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
|
||||
for i := range ignored {
|
||||
ignored[i] = fmt.Sprintf("Field%d", i+1)
|
||||
}
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
"ignored_fields": ignored,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(got, fmt.Sprintf("ignored_fields=%d", len(ignored))) ||
|
||||
!strings.Contains(got, fmt.Sprintf("...(%d total)", len(ignored))) ||
|
||||
strings.Contains(got, "Field22") {
|
||||
t.Fatalf("ignored field truncation mismatch:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownContentSafetyWarnKeepsStdoutClean(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&recordMarkdownCSTestProvider{
|
||||
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "| rec_1 | Alice |") || strings.Contains(got, "content safety") {
|
||||
t.Fatalf("stdout should contain only markdown data, got:\n%s", got)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: content safety alert") {
|
||||
t.Fatalf("stderr missing content safety warning:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&recordMarkdownCSTestProvider{
|
||||
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
|
||||
t.Fatalf("err=%v, want content safety exit error", err)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
t.Fatalf("block mode should not write warning to stderr, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownFallsBackToJSONWhenRenderFails(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name"},
|
||||
"rows": []interface{}{[]interface{}{"Alice"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "markdown render failed") {
|
||||
t.Fatalf("stdout should not contain fallback warning:\n%s", stdout.String())
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout should be JSON fallback, got err=%v stdout=%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK || !strings.Contains(stdout.String(), `"records"`) {
|
||||
t.Fatalf("stdout missing JSON fallback data:\n%s", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: record markdown render failed, falling back to json") {
|
||||
t.Fatalf("stderr missing fallback warning:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownDefaultFormatAllowsJqJSONFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
|
||||
runtime.JqExpr = ".data.record_id_list[0]"
|
||||
err := outputRecordMarkdown(runtime, map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "rec_1" {
|
||||
t.Fatalf("stdout jq fallback mismatch: %q", got)
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
t.Fatalf("stderr should be empty, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownExplicitFormatRejectsJq(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
|
||||
runtime.JqExpr = ".data"
|
||||
if err := runtime.Cmd.Flags().Set("format", "markdown"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
err := outputRecordMarkdown(runtime, map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "--jq and --format markdown are mutually exclusive") {
|
||||
t.Fatalf("err=%v, want jq markdown conflict", err)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("stdout should be empty, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,9 @@ func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
@@ -190,6 +193,9 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -213,6 +219,9 @@ func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordSearch = common.Shortcut{
|
||||
@@ -19,16 +20,28 @@ var BaseRecordSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "record search JSON object", Required: true},
|
||||
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
|
||||
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
|
||||
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
},
|
||||
|
||||
@@ -155,6 +155,19 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client {
|
||||
return ctx.larkSDK
|
||||
}
|
||||
|
||||
// EnsureScopes runs the same pre-flight scope check used by the framework
|
||||
// before Validate, but on a caller-supplied set of scopes. Use it from a
|
||||
// shortcut's Validate to enforce conditional scope requirements that depend
|
||||
// on flag values (e.g. --delete-remote needing space:document:delete) so a
|
||||
// destructive operation never starts on a token that can't finish it.
|
||||
//
|
||||
// Behavior matches checkShortcutScopes: when no token is available or the
|
||||
// resolver doesn't expose scope metadata, this is a silent no-op — the
|
||||
// downstream API call still surfaces missing_scope at runtime.
|
||||
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
|
||||
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
|
||||
}
|
||||
|
||||
// ── Flag accessors ──
|
||||
|
||||
// Str returns a string flag value.
|
||||
@@ -882,17 +895,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
}
|
||||
f, err := rctx.FileIO().Open(path)
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
|
||||
}
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
}
|
||||
data, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
continue
|
||||
|
||||
@@ -5,7 +5,6 @@ package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -60,13 +59,12 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
|
||||
func TestResolveInputFlags_File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
content := "## Hello\n\nThis is **markdown** from a file.\n"
|
||||
fpath := filepath.Join(dir, "test.md")
|
||||
os.WriteFile(fpath, []byte(content), 0644)
|
||||
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -79,6 +77,25 @@ func TestResolveInputFlags_File(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("markdown"); got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyInput(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -132,9 +149,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
|
||||
func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -156,7 +171,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty") {
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ type searchUser struct {
|
||||
P2PChatID string `json:"p2p_chat_id"`
|
||||
HasChatted bool `json:"has_chatted"`
|
||||
Department string `json:"department"`
|
||||
Signature string `json:"signature"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
ChatRecencyHint string `json:"chat_recency_hint"`
|
||||
MatchSegments []string `json:"match_segments"`
|
||||
}
|
||||
@@ -150,18 +150,38 @@ var ContactSearchUser = common.Shortcut{
|
||||
{Name: "left-organization", Type: "bool", Desc: "restrict to users who have left the organization (omit to disable; =false rejected)"},
|
||||
{Name: "lang", Desc: "override locale for localized_name (e.g. zh_cn, en_us)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice' --format json",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me' --format json",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted --format json",
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice'",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
|
||||
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
|
||||
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
|
||||
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateSearchUser(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
|
||||
queries := parseAndDedupQueries(raw)
|
||||
filter, err := buildFanoutFilter(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
api := common.NewDryRunAPI()
|
||||
for _, q := range queries {
|
||||
body := &searchUserAPIRequest{Query: q}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
api.POST(searchUserURL).
|
||||
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
|
||||
Body(body)
|
||||
}
|
||||
return api
|
||||
}
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -175,6 +195,13 @@ var ContactSearchUser = common.Shortcut{
|
||||
}
|
||||
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
}
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,10 +374,32 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
|
||||
func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
if !hasAnySearchInput(runtime) {
|
||||
return common.FlagErrorf(
|
||||
"specify at least one of --query, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
)
|
||||
}
|
||||
|
||||
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
|
||||
if queriesRaw != "" {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return common.FlagErrorf("--query and --queries are mutually exclusive")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
|
||||
@@ -399,6 +448,9 @@ func hasAnySearchInput(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
275
shortcuts/contact/contact_search_user_fanout.go
Normal file
275
shortcuts/contact/contact_search_user_fanout.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFanoutQueries = 20
|
||||
fanoutConcurrency = 5
|
||||
)
|
||||
|
||||
// parseAndDedupQueries splits the raw CSV, trims whitespace, drops empty
|
||||
// entries, and deduplicates case-sensitively while preserving first-occurrence
|
||||
// order.
|
||||
func parseAndDedupQueries(raw string) []string {
|
||||
parts := common.SplitCSV(raw)
|
||||
seen := make(map[string]bool, len(parts))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fanoutResult struct {
|
||||
Index int
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
ErrMsg string // empty = success
|
||||
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
// request; in-flight workers continue until DoAPI returns.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
|
||||
body := &searchUserAPIRequest{Query: query}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: searchUserURL,
|
||||
Body: body,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
body := strings.TrimSpace(string(apiResp.RawBody))
|
||||
const maxBody = 200
|
||||
if len(body) > maxBody {
|
||||
body = body[:maxBody] + "..."
|
||||
}
|
||||
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
|
||||
if body != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, body)
|
||||
}
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: msg,
|
||||
ErrCode: apiResp.StatusCode}
|
||||
}
|
||||
|
||||
var resp searchUserAPIEnvelope
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
|
||||
ErrCode: resp.Code}
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
type fanoutUser struct {
|
||||
searchUser
|
||||
MatchedQuery string `json:"matched_query"`
|
||||
}
|
||||
|
||||
type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
indexed[r.Index] = r
|
||||
}
|
||||
|
||||
out := &fanoutResponse{
|
||||
Users: make([]fanoutUser, 0),
|
||||
Queries: make([]querySummary, 0, len(queries)),
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErrCode int
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErrCode = r.ErrCode
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
}
|
||||
if failed == len(queries) && len(queries) > 0 {
|
||||
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
|
||||
len(queries), firstErrMsg, firstErrQuery)
|
||||
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
|
||||
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
|
||||
// means success in the Lark protocol, so don't pretend it's an API error
|
||||
// when we have nothing structured to report.
|
||||
if firstErrCode != 0 {
|
||||
return nil, output.ErrAPI(firstErrCode, msg, "")
|
||||
}
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func executeSearchUserFanout(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queries := parseAndDedupQueries(runtime.Str("queries"))
|
||||
|
||||
filter, err := buildFanoutFilter(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]fanoutResult, len(queries))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, fanoutConcurrency)
|
||||
|
||||
for i, q := range queries {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, q string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
results[i] = fanoutResult{
|
||||
Index: i,
|
||||
Query: q,
|
||||
ErrMsg: fmt.Sprintf("internal error: %v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
results[i] = runOneQuery(ctx, runtime, i, q, filter)
|
||||
}(i, q)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
resp, err := buildFanoutResponse(queries, results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
failed, hasMoreCount := 0, 0
|
||||
for _, qs := range resp.Queries {
|
||||
if qs.Error != "" {
|
||||
failed++
|
||||
}
|
||||
if qs.HasMore {
|
||||
hasMoreCount++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Users)}, func(w io.Writer) {
|
||||
if len(resp.Users) == 0 {
|
||||
fmt.Fprintln(w, "No users found.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, prettyFanoutUserRows(resp.Users))
|
||||
})
|
||||
|
||||
if isFanoutSummaryFormat(runtime.Format) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total users; %d failed, %d with has_more\n",
|
||||
len(queries), len(resp.Users), failed, hasMoreCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFanoutFilter(runtime *common.RuntimeContext) (*searchUserAPIFilter, error) {
|
||||
filter := &searchUserAPIFilter{}
|
||||
hasFilter := false
|
||||
for _, bf := range searchUserBoolFilters {
|
||||
if runtime.Cmd.Flags().Changed(bf.Flag) && runtime.Bool(bf.Flag) {
|
||||
bf.Apply(filter)
|
||||
hasFilter = true
|
||||
}
|
||||
}
|
||||
if !hasFilter {
|
||||
return nil, nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func prettyFanoutUserRows(users []fanoutUser) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(users))
|
||||
for _, u := range users {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"matched_query": u.MatchedQuery,
|
||||
"localized_name": u.LocalizedName,
|
||||
"department": common.TruncateStr(u.Department, 50),
|
||||
"enterprise_email": u.EnterpriseEmail,
|
||||
"has_chatted": u.HasChatted,
|
||||
"chat_recency_hint": u.ChatRecencyHint,
|
||||
"open_id": u.OpenID,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -5,10 +5,14 @@ package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -620,6 +624,46 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Most users have no signature; the field is omitempty so an empty value
|
||||
// must not appear at all in the JSON, not as "" — agents shouldn't have to
|
||||
// distinguish "absent" from "empty string".
|
||||
func TestSearchUser_Integration_EmptySignatureOmitted(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_a",
|
||||
"meta_data": map[string]interface{}{
|
||||
"i18n_names": map[string]interface{}{"zh_cn": "无签名用户"},
|
||||
"mail_address": "x@example.com",
|
||||
"description": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "json", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
users := got["data"].(map[string]interface{})["users"].([]interface{})
|
||||
u := users[0].(map[string]interface{})
|
||||
if _, present := u["signature"]; present {
|
||||
t.Errorf(`signature must be absent (not "") when empty; got %v`, u["signature"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchUser_Integration_NDJSONHasNoRefineHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -808,6 +852,345 @@ func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func newSearchUserTestCommandWithQueries() *cobra.Command {
|
||||
cmd := newSearchUserTestCommand()
|
||||
cmd.Flags().String("queries", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestValidateQueries_QueryAndQueriesMutex(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
_ = cmd.Flags().Set("query", "alice")
|
||||
_ = cmd.Flags().Set("queries", "bob,carol")
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--query and --queries are mutually exclusive") {
|
||||
t.Fatalf("expected mutex error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_UserIDsAndQueriesMutex(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
_ = cmd.Flags().Set("user-ids", "ou_a")
|
||||
_ = cmd.Flags().Set("queries", "bob")
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--user-ids and --queries are mutually exclusive") {
|
||||
t.Fatalf("expected mutex error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_AllSeparators_Errors(t *testing.T) {
|
||||
for _, raw := range []string{",,,", " , , ", ","} {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
_ = cmd.Flags().Set("queries", raw)
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "no valid query parsed") {
|
||||
t.Fatalf("raw=%q: expected 'no valid query parsed' error, got %v", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_OverLength_Errors(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
long := strings.Repeat("a", 51)
|
||||
_ = cmd.Flags().Set("queries", "short,"+long)
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 50 characters") {
|
||||
t.Fatalf("expected length error mentioning 50, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_Over20_Errors(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
parts := make([]string, 21)
|
||||
for i := range parts {
|
||||
parts[i] = fmt.Sprintf("q%02d", i)
|
||||
}
|
||||
_ = cmd.Flags().Set("queries", strings.Join(parts, ","))
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "must be at most 20 entries") {
|
||||
t.Fatalf("expected 20-cap error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueries_TrimAndSkipEmpty(t *testing.T) {
|
||||
got := parseAndDedupQueries("a, ,b ,")
|
||||
want := []string{"a", "b"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Errorf("parseAndDedupQueries: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueries_DedupCaseSensitive(t *testing.T) {
|
||||
got := parseAndDedupQueries("alice,Alice,alice")
|
||||
want := []string{"alice", "Alice"}
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Errorf("got %v, want %v (case-sensitive dedup keeps first-occurrence order)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSingleQuery_OutputUnchanged(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data, _ := got["data"].(map[string]interface{})
|
||||
if _, hasQueries := data["queries"]; hasQueries {
|
||||
t.Errorf("single-query mode must NOT emit data.queries; got=%v", data)
|
||||
}
|
||||
users, _ := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users len = %d, want 1", len(users))
|
||||
}
|
||||
u, _ := users[0].(map[string]interface{})
|
||||
if _, hasMatched := u["matched_query"]; hasMatched {
|
||||
t.Errorf("single-query mode users[] must NOT carry matched_query; got=%v", u)
|
||||
}
|
||||
if _, hasTopHasMore := data["has_more"]; !hasTopHasMore {
|
||||
t.Errorf("single-query mode must keep top-level data.has_more; data=%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// runOneQueryRuntime wires a Factory-backed RuntimeContext bound to the test
|
||||
// command's flag set, so runOneQuery can be exercised directly without going
|
||||
// through the cobra dispatcher. Mirrors what mountAndRun would build, minus
|
||||
// the parent-command plumbing the worker doesn't need.
|
||||
func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
cmd := newSearchUserTestCommand()
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestRunOneQuery_Success(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(searchUserStub())
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 0, "张三", nil)
|
||||
if got.ErrMsg != "" {
|
||||
t.Fatalf("unexpected ErrMsg: %q", got.ErrMsg)
|
||||
}
|
||||
if got.Index != 0 || got.Query != "张三" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if len(got.Users) != 1 || got.Users[0].OpenID != "ou_a" {
|
||||
t.Errorf("Users mismatch: %+v", got.Users)
|
||||
}
|
||||
if got.HasMore {
|
||||
t.Errorf("HasMore should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneQuery_APINonZeroCode(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "rate limited"},
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 3, "alice", nil)
|
||||
if got.Index != 3 || got.Query != "alice" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if got.ErrMsg != "API 99991663: rate limited" {
|
||||
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
|
||||
}
|
||||
if got.Users != nil || got.HasMore {
|
||||
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneQuery_HTTPNon200(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
Status: 503,
|
||||
Body: map[string]interface{}{"reason": "upstream_unavailable"},
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 1, "bob", nil)
|
||||
if !strings.HasPrefix(got.ErrMsg, "HTTP 503 Service Unavailable: ") {
|
||||
t.Errorf("ErrMsg should start with status line; got %q", got.ErrMsg)
|
||||
}
|
||||
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
|
||||
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
|
||||
}
|
||||
if got.ErrCode != 503 {
|
||||
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneQuery_HTTPNon200_BodyTruncated(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
long := strings.Repeat("x", 1000)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"detail": long},
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 0, "alice", nil)
|
||||
if !strings.HasSuffix(got.ErrMsg, "...") {
|
||||
t.Errorf("oversized body should be truncated with '...' suffix; got %q", got.ErrMsg)
|
||||
}
|
||||
if len(got.ErrMsg) > 300 {
|
||||
t.Errorf("ErrMsg %d chars exceeds reasonable budget; got %q", len(got.ErrMsg), got.ErrMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// SDK-level transport / envelope-unmarshal failures arrive as Go errors from
|
||||
// runtime.DoAPI; the worker converts them by calling err.Error() rather than
|
||||
// adding its own prefix, so the assertion here is "ErrMsg is non-empty and
|
||||
// preserves the underlying message" — the exact text comes from the SDK.
|
||||
func TestRunOneQuery_TransportError(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 2, "carol", nil)
|
||||
if got.ErrMsg == "" {
|
||||
t.Fatalf("expected non-empty ErrMsg for malformed body")
|
||||
}
|
||||
if got.Index != 2 || got.Query != "carol" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if got.Users != nil || got.HasMore {
|
||||
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
|
||||
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}, HasMore: false},
|
||||
{Index: 2, Query: "carol", ErrMsg: "API 1: nope"},
|
||||
}
|
||||
resp, err := buildFanoutResponse([]string{"alice", "bob", "carol"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(resp.Users) != 3 {
|
||||
t.Fatalf("Users length: got %d, want 3 (carol failed → 0 users)", len(resp.Users))
|
||||
}
|
||||
if resp.Users[0].OpenID != "ou_a1" || resp.Users[0].MatchedQuery != "alice" {
|
||||
t.Errorf("Users[0]: got %+v", resp.Users[0])
|
||||
}
|
||||
if resp.Users[1].OpenID != "ou_a2" || resp.Users[1].MatchedQuery != "alice" {
|
||||
t.Errorf("Users[1]: got %+v", resp.Users[1])
|
||||
}
|
||||
if resp.Users[2].OpenID != "ou_b" || resp.Users[2].MatchedQuery != "bob" {
|
||||
t.Errorf("Users[2]: got %+v", resp.Users[2])
|
||||
}
|
||||
if len(resp.Queries) != 3 {
|
||||
t.Fatalf("Queries length: got %d, want 3 (full enumeration)", len(resp.Queries))
|
||||
}
|
||||
want := []querySummary{
|
||||
{Query: "alice", Error: "", HasMore: false},
|
||||
{Query: "bob", Error: "", HasMore: true},
|
||||
{Query: "carol", Error: "API 1: nope", HasMore: false},
|
||||
}
|
||||
for i, w := range want {
|
||||
if resp.Queries[i] != w {
|
||||
t.Errorf("Queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit"},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500 Internal Server Error"},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Errorf("expected first error (rate limit) to be returned; got %v", err)
|
||||
}
|
||||
// Document the count is part of the message — agents grep for it.
|
||||
if !strings.Contains(err.Error(), "all 2 queries failed") {
|
||||
t.Errorf("expected 'all 2 queries failed' substring; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Codes from the first failure must propagate through output.ErrAPI so the
|
||||
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
|
||||
// instead of 0, which would mean "success" in the Lark protocol.
|
||||
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Errorf("error should contain first ErrMsg; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a"}}},
|
||||
{Index: 1, Query: "bob", ErrMsg: "API 5: not found"},
|
||||
}
|
||||
resp, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure must NOT be a hard error; got %v", err)
|
||||
}
|
||||
if len(resp.Users) != 1 {
|
||||
t.Errorf("Users: got %d, want 1", len(resp.Users))
|
||||
}
|
||||
if resp.Queries[1].Error != "API 5: not found" {
|
||||
t.Errorf("Queries[1].Error: got %q", resp.Queries[1].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", HasMore: true},
|
||||
}
|
||||
resp, err := buildFanoutResponse([]string{"alice"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
var asMap map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &asMap); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := asMap["has_more"]; ok {
|
||||
t.Errorf("fanoutResponse must not have top-level has_more; got %v", asMap)
|
||||
}
|
||||
if _, ok := asMap["users"]; !ok {
|
||||
t.Errorf("fanoutResponse missing users")
|
||||
}
|
||||
if _, ok := asMap["queries"]; !ok {
|
||||
t.Errorf("fanoutResponse missing queries")
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
|
||||
// are no longer accepted. cobra must reject the unknown flag at parse time —
|
||||
// no stub is registered because the command should never reach the API.
|
||||
@@ -827,3 +1210,341 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--has-chatted",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(stub.CapturedBodies) < 2 {
|
||||
t.Fatalf("expected ≥2 captured request bodies, got %d", len(stub.CapturedBodies))
|
||||
}
|
||||
bodyByQuery := map[string]map[string]interface{}{}
|
||||
for i, raw := range stub.CapturedBodies {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal req %d: %v", i, err)
|
||||
}
|
||||
bodyByQuery[body["query"].(string)] = body
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil || filter["has_contact"] != true {
|
||||
t.Errorf("req %d (query=%v): expected filter.has_contact=true; got body=%v", i, body["query"], body)
|
||||
}
|
||||
}
|
||||
if _, ok := bodyByQuery["alice"]; !ok {
|
||||
t.Errorf("missing request for query=alice; captured=%v", bodyByQuery)
|
||||
}
|
||||
if _, ok := bodyByQuery["bob"]; !ok {
|
||||
t.Errorf("missing request for query=bob; captured=%v", bodyByQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"bob"`) },
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should NOT propagate as error; got %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
users := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
|
||||
t.Errorf("queries[1].error: got %q", q1["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_AllFailed_ExitNonZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Status: 500, Body: map[string]interface{}{"reason": "boom"},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
// First failure's HTTP code (500) and a digestible reason must propagate
|
||||
// so agents can classify (vs. a generic ExitInternal masking the upstream).
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "500") {
|
||||
t.Errorf("error must propagate first failure's HTTP 500 code; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "all 2 queries failed") {
|
||||
t.Errorf("error must indicate the all-failed mode; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_ConcurrencyLimitFive(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
var inFlight, peak int32
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
OnMatch: func(req *http.Request) {
|
||||
cur := atomic.AddInt32(&inFlight, 1)
|
||||
defer atomic.AddInt32(&inFlight, -1)
|
||||
for {
|
||||
p := atomic.LoadInt32(&peak)
|
||||
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
},
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
|
||||
})
|
||||
|
||||
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", strings.Join(queries, ","),
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if peak > 5 {
|
||||
t.Errorf("concurrency peak = %d, want ≤ 5", peak)
|
||||
}
|
||||
if peak < 2 {
|
||||
t.Errorf("concurrency peak = %d, want ≥ 2 (test should observe parallelism)", peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_PanicRecovery(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"boom"`) },
|
||||
OnMatch: func(req *http.Request) {
|
||||
panic("synthetic test panic")
|
||||
},
|
||||
Body: map[string]interface{}{},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("partial panic must not bubble; got %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
_ = json.Unmarshal(stdout.Bytes(), &got)
|
||||
queries := got["data"].(map[string]interface{})["queries"].([]interface{})
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "internal error:") {
|
||||
t.Errorf("queries[1].error: expected 'internal error:' prefix, got %q", q1["error"])
|
||||
}
|
||||
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
|
||||
if strings.Contains(stderr.String(), marker) {
|
||||
t.Errorf("stderr leaked stack-trace marker %q; got=%s", marker, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_MatchedQueryFidelity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_x"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "张三,Alice 王", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
_ = json.Unmarshal(stdout.Bytes(), &got)
|
||||
users := got["data"].(map[string]interface{})["users"].([]interface{})
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("users: got %d, want 2", len(users))
|
||||
}
|
||||
want := []string{"张三", "Alice 王"}
|
||||
for i, w := range want {
|
||||
mq := users[i].(map[string]interface{})["matched_query"]
|
||||
if mq != w {
|
||||
t.Errorf("users[%d].matched_query: got %v, want %q (must be original input verbatim)", i, mq, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_NDJSONStdoutClean(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "a,a,b", "--format", "ndjson", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, marker := range []string{"queries,", "total users", "with has_more"} {
|
||||
if strings.Contains(stdout.String(), marker) {
|
||||
t.Errorf("ndjson stdout must not contain %q; got=%q", marker, stdout.String())
|
||||
}
|
||||
}
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestFanout_CSVHasMatchedQueryColumn(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--format", "csv", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "matched_query") {
|
||||
t.Errorf("csv stdout must include matched_query column; got=%q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "queries") || !strings.Contains(stderr.String(), "total users") {
|
||||
t.Errorf("csv summary should land on stderr; got=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--has-chatted", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"alice", "bob", "POST", "/contact/v3/users/search", "has_contact"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("dry-run output missing %q; got=%q", want, out)
|
||||
}
|
||||
}
|
||||
// One DryRunAPI description per query.
|
||||
if strings.Count(out, "/contact/v3/users/search") < 2 {
|
||||
t.Errorf("dry-run should describe ≥2 API calls (one per query); got=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Spec §7 promises single-query --query mode is "零变化". The fanout summary
|
||||
// hint was broadened to csv (good — stderr can carry it without corrupting
|
||||
// the csv stream on stdout); the single-query refine hint must NOT inherit
|
||||
// that broadening, since pre-fanout it only fired on pretty/table.
|
||||
func TestSearchUser_Integration_CSVSingleQueryNoRefineHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": true,
|
||||
"page_token": "tok_next",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "csv", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "refine") {
|
||||
t.Errorf("single-query --format csv must NOT emit the refine hint; got stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-canceled ctx must be observed by runOneQuery before it dispatches the
|
||||
// HTTP call. The error string is exactly "context canceled" because that's
|
||||
// what context.Context.Err().Error() returns — agents may grep for it.
|
||||
func TestRunOneQuery_CtxCanceledEarly(t *testing.T) {
|
||||
rt, _ := runOneQueryRuntime(t)
|
||||
// Deliberately register no stub: runOneQuery must short-circuit before
|
||||
// touching the transport, so the absence of a stub is the assertion.
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
got := runOneQuery(ctx, rt, 0, "alice", nil)
|
||||
if got.ErrMsg != "context canceled" {
|
||||
t.Errorf("ErrMsg: got %q, want %q", got.ErrMsg, "context canceled")
|
||||
}
|
||||
if got.Index != 0 || got.Query != "alice" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ var DocsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
@@ -110,6 +111,11 @@ func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+create")
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
args := buildCreateArgsV1(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
@@ -122,8 +128,9 @@ func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
md := runtime.Str("markdown")
|
||||
args := map[string]interface{}{
|
||||
"markdown": runtime.Str("markdown"),
|
||||
"markdown": md,
|
||||
}
|
||||
if v := runtime.Str("title"); v != "" {
|
||||
args["title"] = v
|
||||
|
||||
@@ -49,6 +49,7 @@ var DocsFetch = common.Shortcut{
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
|
||||
@@ -22,7 +22,7 @@ func v2FetchFlags() []common.Flag {
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
|
||||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
||||
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
|
||||
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
|
||||
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
|
||||
|
||||
@@ -64,6 +64,7 @@ var DocsUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
@@ -159,6 +160,12 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -306,6 +308,113 @@ func fixSetextAmbiguity(md string) string {
|
||||
return setextRe.ReplaceAllString(md, "$1\n\n$2")
|
||||
}
|
||||
|
||||
// calloutTypeColors maps the semantic type= shorthand to a recommended
|
||||
// [background-color, border-color] pair for Feishu callout blocks.
|
||||
// Used only for hint messages — the Markdown itself is never rewritten.
|
||||
var calloutTypeColors = map[string][2]string{
|
||||
"warning": {"light-yellow", "yellow"},
|
||||
"caution": {"light-orange", "orange"},
|
||||
"note": {"light-blue", "blue"},
|
||||
"info": {"light-blue", "blue"},
|
||||
"tip": {"light-green", "green"},
|
||||
"success": {"light-green", "green"},
|
||||
"check": {"light-green", "green"},
|
||||
"error": {"light-red", "red"},
|
||||
"danger": {"light-red", "red"},
|
||||
"important": {"light-purple", "purple"},
|
||||
}
|
||||
|
||||
// calloutOpenTagRe matches a <callout …> opening tag.
|
||||
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
|
||||
|
||||
// calloutTypeAttrRe extracts the value of a type= attribute (single or
|
||||
// double quoted) from a callout opening tag's attribute string. The
|
||||
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
|
||||
// word/non-word boundary, and `-` is a non-word character, so
|
||||
// `\btype=` would also match the suffix of `data-type=` and yield a
|
||||
// bogus type lookup. Anchoring on start-of-string-or-whitespace
|
||||
// requires a real attribute separator before the name.
|
||||
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
|
||||
|
||||
// calloutBackgroundColorAttrRe matches a background-color= attribute
|
||||
// name with optional whitespace around the equals sign, so forms like
|
||||
// `background-color="..."` and `background-color = "..."` are both
|
||||
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
|
||||
// reason: `data-background-color="..."` must not look like a present
|
||||
// background-color and silently suppress the hint.
|
||||
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
|
||||
|
||||
// WarnCalloutType scans md for callout tags that carry a type= attribute but
|
||||
// no background-color= attribute, then writes a hint line to w for each one
|
||||
// suggesting the explicit Feishu color attributes to use instead.
|
||||
//
|
||||
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
|
||||
// are documentation samples, not real callouts the user wants Feishu to
|
||||
// render. Fence detection uses the shared codeFenceOpenMarker /
|
||||
// isCodeFenceClose helpers so both backtick and tilde fences are handled
|
||||
// (matching CommonMark §4.5).
|
||||
//
|
||||
// The Markdown is not modified — the caller is responsible for acting on
|
||||
// the hints or ignoring them. This keeps the create/update path
|
||||
// transparent: user input reaches create-doc exactly as written.
|
||||
func WarnCalloutType(md string, w io.Writer) {
|
||||
fenceMarker := ""
|
||||
for _, line := range strings.Split(md, "\n") {
|
||||
if fenceMarker != "" {
|
||||
// Inside a fenced block — skip everything until the matching
|
||||
// closer. Code samples that show literal <callout type=...>
|
||||
// must not produce a phantom hint.
|
||||
if isCodeFenceClose(line, fenceMarker) {
|
||||
fenceMarker = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
||||
fenceMarker = marker
|
||||
continue
|
||||
}
|
||||
scanCalloutTagsForWarning(line, w)
|
||||
}
|
||||
}
|
||||
|
||||
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
|
||||
// tag in s that lacks an explicit background-color= attribute. Pulled out
|
||||
// of WarnCalloutType so the line walker only handles fence state and the
|
||||
// per-tag scan is its own readable unit.
|
||||
//
|
||||
// The previous implementation routed the tag iteration through
|
||||
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
|
||||
// returned the original tag and threw the rebuilt string away — using a
|
||||
// rewrite primitive purely for its iteration side-effect, plus a second
|
||||
// regex execution to recover the capture groups inside the callback.
|
||||
// FindAllStringSubmatch hands us both the iteration and the groups in one
|
||||
// pass, no allocation thrown away.
|
||||
func scanCalloutTagsForWarning(s string, w io.Writer) {
|
||||
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
|
||||
attrs := m[1]
|
||||
// Skip tags that already carry an explicit background-color.
|
||||
if calloutBackgroundColorAttrRe.MatchString(attrs) {
|
||||
continue
|
||||
}
|
||||
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
|
||||
if len(parts) < 3 {
|
||||
continue // no type= attribute
|
||||
}
|
||||
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
|
||||
typeName := parts[1]
|
||||
if typeName == "" {
|
||||
typeName = parts[2]
|
||||
}
|
||||
colors, ok := calloutTypeColors[typeName]
|
||||
if !ok {
|
||||
continue // unknown type — no hint to give
|
||||
}
|
||||
fmt.Fprintf(w,
|
||||
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
|
||||
typeName, colors[0], colors[1])
|
||||
}
|
||||
}
|
||||
|
||||
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
|
||||
// Unicode emoji characters that create-doc accepts.
|
||||
var calloutEmojiAliases = map[string]string{
|
||||
|
||||
@@ -359,6 +359,135 @@ func TestFixExportedMarkdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnCalloutType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantHint bool // whether a hint line is expected
|
||||
hintContains string // substring the hint must contain
|
||||
}{
|
||||
{
|
||||
name: "warning type without background-color emits hint",
|
||||
input: `<callout type="warning" emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "info type without background-color emits hint",
|
||||
input: `<callout type="info" emoji="ℹ️">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-blue"`,
|
||||
},
|
||||
{
|
||||
name: "single-quoted type attribute emits hint",
|
||||
input: `<callout type='warning' emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "explicit background-color suppresses hint",
|
||||
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace around equals is tolerated in background-color",
|
||||
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "unknown type emits no hint",
|
||||
input: `<callout type="custom" emoji="🔥">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "no type attribute emits no hint",
|
||||
input: `<callout emoji="💡" background-color="light-green">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "non-callout tag emits no hint",
|
||||
input: `<div type="warning">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "hint includes border-color suggestion",
|
||||
input: `<callout type="error" emoji="❌">`,
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
{
|
||||
// Regression: the old `\btype=` regex matched the suffix of
|
||||
// `data-type=` because `-` is a non-word character, so a tag
|
||||
// carrying only data-attrs would silently get a bogus hint.
|
||||
// The (?:^|\s) anchor requires a real attribute separator.
|
||||
name: "data-type attribute does not trigger hint",
|
||||
input: `<callout data-type="warning" emoji="📝">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Symmetric guard for the background-color regex: a future
|
||||
// `data-background-color=` attribute must not be mistaken
|
||||
// for a present background-color and silently suppress the
|
||||
// hint that the real type= would otherwise produce.
|
||||
name: "data-background-color does not suppress hint",
|
||||
input: `<callout type="warning" data-background-color="anything">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
// Regression for the code-fence skip: a documentation sample
|
||||
// inside a ``` fence is NOT a real callout the user wants
|
||||
// rendered, so it must produce no stderr noise.
|
||||
name: "callout inside backtick fence emits no hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning" emoji="📝">` + "\n" +
|
||||
"```\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Same skip works for tilde fences (CommonMark §4.5 makes
|
||||
// `~~~` an equivalent fence character).
|
||||
name: "callout inside tilde fence emits no hint",
|
||||
input: "~~~markdown\n" +
|
||||
`<callout type="info" emoji="ℹ️">` + "\n" +
|
||||
"~~~\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Closing the fence must restore normal scanning: a real
|
||||
// callout that follows a documentation block still gets a
|
||||
// hint. Pins that fenceMarker is reset, not stuck.
|
||||
name: "callout after fence close still emits hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning">sample</callout>` + "\n" +
|
||||
"```\n" +
|
||||
`<callout type="error" emoji="❌">real</callout>` + "\n",
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
WarnCalloutType(tt.input, &buf)
|
||||
got := buf.String()
|
||||
if tt.wantHint {
|
||||
if got == "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
|
||||
return
|
||||
}
|
||||
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
|
||||
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
|
||||
}
|
||||
} else {
|
||||
if got != "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCalloutEmoji(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -3,7 +3,24 @@
|
||||
|
||||
package doc
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsServiceHelpDefault = `Document and content operations.`
|
||||
|
||||
const docsServiceHelpV2 = `Document and content operations (v2).`
|
||||
|
||||
var docsVersionSelectionTips = []string{
|
||||
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
|
||||
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
|
||||
}
|
||||
|
||||
// Shortcuts returns all docs shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
@@ -18,3 +35,48 @@ func Shortcuts() []common.Shortcut {
|
||||
DocMediaDownload,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
||||
// The shortcut-level help remains compatible with legacy v1 skills; this parent
|
||||
// help gives agents enough context to choose v2 only when their installed skill
|
||||
// explicitly asks for `--api-version v2`.
|
||||
func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
serviceCmd := cmd
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
if cmd.Flags().Lookup("api-version") == nil {
|
||||
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
defaultHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != serviceCmd {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
apiVersion, _ := cmd.Flags().GetString("api-version")
|
||||
previousLong := cmd.Long
|
||||
if apiVersion == "v2" {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
|
||||
} else {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Long = previousLong
|
||||
}()
|
||||
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range docsVersionSelectionTips {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
|
||||
}
|
||||
})
|
||||
origHelp(cmd, args)
|
||||
if ver == "v1" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
|
||||
" Use --api-version v2 for the latest API:\n"+
|
||||
" %s %s --api-version v2 --help\n",
|
||||
cmd.Parent().Name(), cmd.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
@@ -54,14 +55,19 @@ var DriveExport = common.Shortcut{
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
return common.NewDryRunAPI().
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
})
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -73,10 +79,15 @@ var DriveExport = common.Shortcut{
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body)
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
@@ -86,6 +97,7 @@ var DriveExport = common.Shortcut{
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
@@ -106,14 +118,18 @@ var DriveExport = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -166,7 +182,11 @@ var DriveExport = common.Shortcut{
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
@@ -227,7 +247,7 @@ var DriveExport = common.Shortcut{
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
runtime.Out(map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -238,7 +258,11 @@ var DriveExport = common.Shortcut{
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}, nil)
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -130,6 +130,155 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "custom-notes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# custom\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "custom-notes.md"`) {
|
||||
t.Fatalf("stdout missing provided file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantURL string
|
||||
wantFileName string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
wantURL: "/open-apis/docs/v1/content",
|
||||
wantFileName: `"file_name": "notes.md"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "notes",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "async export",
|
||||
wantURL: "/open-apis/drive/v1/export_tasks",
|
||||
wantFileName: `"file_name": "report.pdf"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, tt.wantURL) {
|
||||
t.Fatalf("stdout missing URL %q: %s", tt.wantURL, out)
|
||||
}
|
||||
if !strings.Contains(out, tt.wantFileName) {
|
||||
t.Fatalf("stdout missing file_name metadata %q: %s", tt.wantFileName, out)
|
||||
}
|
||||
if !strings.Contains(out, `"output_dir": "./exports"`) {
|
||||
t.Fatalf("stdout missing output_dir metadata: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "metadata unavailable",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# fallback\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "docx123.md"`) {
|
||||
t.Fatalf("stdout missing fallback file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -200,6 +349,77 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_custom"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_custom",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_custom",
|
||||
"file_name": "server-name",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_custom/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="server-name.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "custom-report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "custom-report.pdf"`) {
|
||||
t.Fatalf("stdout missing provided file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
@@ -425,6 +645,51 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportTimeoutPreservesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_name"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_name",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "quarterly-report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "quarterly-report.pdf"`) {
|
||||
t.Fatalf("stdout missing preserved file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
337
shortcuts/drive/drive_pull.go
Normal file
337
shortcuts/drive/drive_pull.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"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/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePullIfExistsOverwrite = "overwrite"
|
||||
drivePullIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DrivePull performs a one-way file-level mirror from a Drive folder onto
|
||||
// a local directory: recursively lists --folder-token, downloads each
|
||||
// type=file entry under --local-dir, and optionally deletes local files
|
||||
// absent from Drive (--delete-local --yes).
|
||||
//
|
||||
// Only Drive entries with type=file participate; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
// equivalent local binary to write back. Directories are reproduced when
|
||||
// remote folders contain downloadable files, but local directories that
|
||||
// become orphaned after a remote folder is removed are NOT pruned —
|
||||
// --delete-local only unlinks regular files.
|
||||
var DrivePull = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+pull",
|
||||
Description: "One-way file-level mirror of a Drive folder onto a local directory (Drive → local)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
|
||||
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
|
||||
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
|
||||
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
|
||||
if ifExists == "" {
|
||||
ifExists = drivePullIfExistsOverwrite
|
||||
}
|
||||
deleteLocal := runtime.Bool("delete-local")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before we
|
||||
// touch the filesystem. SafeInputPath fully evaluates symlinks
|
||||
// across the entire path; this matters because filepath.Clean
|
||||
// alone shrinks "link/.." to "." while the kernel resolves it
|
||||
// through the symlink target's parent — meaning a raw walk on
|
||||
// the user-supplied string can land outside cwd. Walking the
|
||||
// canonical root sidesteps that, and using cwd canonical lets
|
||||
// us emit cwd-relative download targets that FileIO.Save's
|
||||
// SafeOutputPath check still accepts. The risk is much higher
|
||||
// here than in +status because --delete-local would otherwise
|
||||
// remove the wrong files outside cwd.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
// rootRelToCwd is the localDir form FileIO.Save accepts (it
|
||||
// rejects absolute paths). For cwd itself it becomes ".", which
|
||||
// joins cleanly with the rel_paths returned by the lister.
|
||||
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives the download/skip loop (only type=file
|
||||
// has hashable bytes the local mirror can write back).
|
||||
// - remotePaths is the --delete-local guard: it carries every
|
||||
// rel_path Drive owns regardless of type, so a local file
|
||||
// shadowed by a remote folder / online doc / shortcut is NOT
|
||||
// treated as orphaned.
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
for rel, entry := range entries {
|
||||
remotePaths[rel] = struct{}{}
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
downloadablePaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
downloadablePaths = append(downloadablePaths, p)
|
||||
}
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
token := remoteFiles[rel]
|
||||
target := filepath.Join(rootRelToCwd, rel)
|
||||
|
||||
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
|
||||
// Mirror conflict: remote is a regular file but local
|
||||
// has a directory at the same rel_path. Neither
|
||||
// "skipped" nor "downloaded" describes reality —
|
||||
// SafeOutputPath would refuse to write a file over a
|
||||
// directory, and pretending the directory is a
|
||||
// pre-existing file under --if-exists=skip silently
|
||||
// hides the conflict. Surface as a failure.
|
||||
if info.IsDir() {
|
||||
items = append(items, drivePullItem{
|
||||
RelPath: rel,
|
||||
FileToken: token,
|
||||
Action: "failed",
|
||||
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
|
||||
})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
if ifExists == drivePullIfExistsSkip {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
|
||||
downloaded++
|
||||
}
|
||||
|
||||
// Gate --delete-local on a clean download pass. With download
|
||||
// failures still in items[], proceeding to the delete walk would
|
||||
// leave the mirror in a half-synced state where some files Drive
|
||||
// owns are missing locally AND some local-only files have been
|
||||
// removed. Surface the failure first; the operator can re-run
|
||||
// after fixing whatever caused the download error.
|
||||
if deleteLocal && downloadFailed == 0 {
|
||||
// Walk the canonical absolute root, build the list of
|
||||
// rel_paths, then delete via the absolute path. Both
|
||||
// values come from the validated safeRoot, so kernel
|
||||
// path resolution cannot redirect the delete to a file
|
||||
// outside the canonical subtree.
|
||||
localAbsPaths, err := drivePullWalkLocal(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, absPath := range localAbsPaths {
|
||||
rel, relErr := filepath.Rel(safeRoot, absPath)
|
||||
if relErr != nil {
|
||||
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
// Consult remotePaths (every Drive entry, regardless of
|
||||
// type) rather than remoteFiles (downloadable subset
|
||||
// only). Otherwise an online doc / shortcut at e.g.
|
||||
// "notes.docx" would leave a same-named local file
|
||||
// looking orphaned and get unlinked even though Drive
|
||||
// still knows about that path.
|
||||
if _, ok := remotePaths[rel]; ok {
|
||||
continue
|
||||
}
|
||||
// FileIO has no Remove(); the absolute path comes from
|
||||
// walking safeRoot, which validate.SafeInputPath has
|
||||
// already bounded inside cwd, so a bare os.Remove is
|
||||
// acceptable here. Shortcuts cannot import internal/vfs
|
||||
// directly (depguard rule shortcuts-no-vfs).
|
||||
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"})
|
||||
deletedLocal++
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
// Item-level failures (download error, dir/file conflict, delete
|
||||
// error) must surface as a non-zero exit so AI / script callers
|
||||
// don't have to reach into summary.failed to detect a partial
|
||||
// sync. The same structured payload rides along in error.detail
|
||||
// so forensics aren't lost. When --delete-local was skipped
|
||||
// because of an earlier download failure, callers see
|
||||
// deleted_local=0 plus the download failure that aborted it,
|
||||
// which is what makes the partial state self-explanatory.
|
||||
if failed > 0 {
|
||||
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
|
||||
if deleteLocal && downloadFailed > 0 {
|
||||
msg += " (--delete-local was skipped because the download pass had failures)"
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_failure",
|
||||
Message: msg,
|
||||
Detail: payload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body); err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drivePullWalkLocal walks the canonical absolute root and returns the
|
||||
// absolute paths of every regular file underneath it. The caller deletes
|
||||
// some of these paths, so it is critical that they are produced by
|
||||
// walking a canonical root (no symlinks in the path) — otherwise OS path
|
||||
// resolution could redirect a delete to a file outside cwd. Same threat
|
||||
// model as drive_status.go.
|
||||
func drivePullWalkLocal(root string) ([]string, error) {
|
||||
var paths []string
|
||||
// FileIO has no walker today; shortcuts cannot import internal/vfs
|
||||
// (depguard rule shortcuts-no-vfs). The root passed in is the
|
||||
// canonical absolute path returned by validate.SafeInputPath, so
|
||||
// WalkDir's default "do not follow child symlinks" policy keeps the
|
||||
// traversal inside the validated subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, absPath)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
1026
shortcuts/drive/drive_pull_test.go
Normal file
1026
shortcuts/drive/drive_pull_test.go
Normal file
File diff suppressed because it is too large
Load Diff
717
shortcuts/drive/drive_push.go
Normal file
717
shortcuts/drive/drive_push.go
Normal file
@@ -0,0 +1,717 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePushIfExistsOverwrite = "overwrite"
|
||||
drivePushIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
type drivePushItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
// Drive folder: walks --local-dir, recursively lists --folder-token, and for
|
||||
// each rel_path uploads (or overwrites) the corresponding Drive file. With
|
||||
// --delete-remote --yes, any type=file entry on Drive that has no local
|
||||
// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts
|
||||
// and folders are never deleted, so this is "file-level" mirror — the
|
||||
// command does not attempt to remove remote-only directories or close gaps
|
||||
// in directory structure that exists on Drive but not locally.
|
||||
//
|
||||
// Only Drive entries with type=file participate in upload/overwrite/delete;
|
||||
// online documents have no equivalent local binary. Sub-folders are created
|
||||
// on Drive on demand via /open-apis/drive/v1/files/create_folder so the
|
||||
// remote tree mirrors the local tree.
|
||||
//
|
||||
// The overwrite path passes the existing file_token as a form field on
|
||||
// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite
|
||||
// contract in shortcuts/markdown. The Drive backend exposing that field is
|
||||
// being rolled out; until rollout completes, --if-exists defaults to "skip"
|
||||
// so the safe path (do not touch existing remote files) is the default and
|
||||
// callers must opt into "overwrite" explicitly.
|
||||
var DrivePush = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+push",
|
||||
Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)",
|
||||
Risk: "write",
|
||||
// Narrowed scopes follow the precedent set by drive +status / +pull:
|
||||
// drive:drive is policy-disabled in some tenants, so this shortcut sticks
|
||||
// to the smallest set the *core* path needs. space:folder:create is
|
||||
// always declared because mirroring a non-flat tree calls
|
||||
// /open-apis/drive/v1/files/create_folder on demand and we want the
|
||||
// framework's pre-flight scope check to catch missing grants before any
|
||||
// upload — otherwise a partial push could land top-level files and then
|
||||
// trip on a missing folder grant for a sub-tree, leaving a half-synced
|
||||
// state.
|
||||
//
|
||||
// space:document:delete is intentionally NOT in the default set even
|
||||
// though --delete-remote needs it. The framework pre-check (runner.go
|
||||
// checkShortcutScopes) runs unconditionally before Validate / dry-run,
|
||||
// so declaring it here would make every plain push (and every
|
||||
// --dry-run) fail for callers that only granted upload scopes.
|
||||
//
|
||||
// Instead, Validate runs a *conditional* pre-flight via
|
||||
// runtime.EnsureScopes when both --delete-remote and --yes are on, so
|
||||
// the missing grant fails the run upfront — before any upload —
|
||||
// rather than landing files first and tripping on missing_scope when
|
||||
// the cleanup pass tries to delete. That avoids the half-synced state
|
||||
// (files uploaded, orphans never cleaned up) that the unconditional
|
||||
// pre-check would otherwise prevent only by also blocking plain
|
||||
// pushes.
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
|
||||
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
|
||||
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
|
||||
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
|
||||
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
|
||||
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
|
||||
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
|
||||
}
|
||||
// Conditional scope pre-check: when --delete-remote --yes is set, the
|
||||
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
|
||||
// upload phase. The default Scopes list intentionally omits
|
||||
// space:document:delete so plain pushes don't get blocked on a grant
|
||||
// they don't need (see the Scopes block above), but at this point we
|
||||
// know the run will need it — pre-flight here so a missing grant
|
||||
// fails before any upload, instead of after, which would otherwise
|
||||
// leave the tenant in a half-synced state (files uploaded, remote
|
||||
// orphans never cleaned up). EnsureScopes is a silent no-op when no
|
||||
// token / scope metadata is available, so test envs and tenants
|
||||
// where the resolver doesn't expose scopes still proceed and rely on
|
||||
// the API-level missing_scope error.
|
||||
if runtime.Bool("delete-remote") && runtime.Bool("yes") {
|
||||
if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
|
||||
if ifExists == "" {
|
||||
// Default to the safe "skip" policy: do not touch already-present
|
||||
// remote files. Callers must pass --if-exists=overwrite to opt
|
||||
// into the overwrite-with-version path that depends on the
|
||||
// rolling-out upload_all `file_token`/`version` protocol field.
|
||||
ifExists = drivePushIfExistsSkip
|
||||
}
|
||||
deleteRemote := runtime.Bool("delete-remote")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
// which closes the kernel-level escape route that filepath.Clean
|
||||
// alone misses (e.g. "link/.." string-cleans to "." but the kernel
|
||||
// resolves through link's target's parent). Walking the canonical
|
||||
// root sidesteps that, and the matching cwd canonical lets each
|
||||
// absolute walk hit be converted to a cwd-relative path that
|
||||
// FileIO.Open's SafeInputPath check still accepts.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives upload / overwrite / orphan-delete
|
||||
// decisions (only type=file entries are upload candidates;
|
||||
// online docs / shortcuts are intentionally never overwritten
|
||||
// or deleted by --delete-remote).
|
||||
// - remoteFolders is the create_folder cache: lets the upload
|
||||
// path skip create_folder when an intermediate folder already
|
||||
// exists, and keeps directory recreation idempotent across
|
||||
// reruns.
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
for rel, entry := range entries {
|
||||
switch entry.Type {
|
||||
case driveTypeFile:
|
||||
remoteFiles[rel] = entry
|
||||
case driveTypeFolder:
|
||||
remoteFolders[rel] = entry
|
||||
}
|
||||
}
|
||||
|
||||
var uploaded, skipped, failed, deletedRemote int
|
||||
items := make([]drivePushItem, 0)
|
||||
// uploadFailed tracks whether any folder-creation, upload or
|
||||
// overwrite step failed. The --delete-remote phase only runs when
|
||||
// this stays false: a partial upload that then proceeds to delete
|
||||
// remote orphans would leave the tenant half-synced (files missing
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
// extended in-place as drivePushEnsureFolder mints new ones.
|
||||
folderCache := map[string]string{"": folderToken}
|
||||
for relDir, entry := range remoteFolders {
|
||||
folderCache[relDir] = entry.FileToken
|
||||
}
|
||||
|
||||
// Mirror local directory structure first, so empty directories
|
||||
// are not silently dropped. Pre-creating also frees the upload
|
||||
// loop from doing on-demand mkdir for every file's parent chain
|
||||
// (the cache makes both paths idempotent, but pre-creation keeps
|
||||
// items[] in a tidy "folders, then files" shape).
|
||||
for _, relDir := range localDirs {
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
// Folder already exists on Drive — nothing to do; staying
|
||||
// silent (no items[] entry) avoids noise on reruns.
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
|
||||
}
|
||||
|
||||
// Upload local-only and overwrite/skip already-present files in a
|
||||
// stable order so output is reproducible.
|
||||
localPaths := make([]string, 0, len(localFiles))
|
||||
for p := range localFiles {
|
||||
localPaths = append(localPaths, p)
|
||||
}
|
||||
sort.Strings(localPaths)
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
|
||||
if entry, ok := remoteFiles[rel]; ok {
|
||||
if ifExists == drivePushIfExistsSkip {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken)
|
||||
if upErr != nil {
|
||||
// Token contract on overwrite failure: an in-place
|
||||
// overwrite preserves the file's token, so the
|
||||
// existing entry.FileToken is normally still the
|
||||
// authoritative pointer to the (possibly already
|
||||
// rewritten) Drive file. But the protocol does not
|
||||
// strictly forbid the backend from minting a new
|
||||
// token, and a partial-success response can return a
|
||||
// non-empty file_token alongside an error (the
|
||||
// missing-version case below is the immediate
|
||||
// concern: bytes hit the disk, version field
|
||||
// missing, so we surface a structured error). Prefer
|
||||
// the freshly returned token when one was produced,
|
||||
// fall back to entry.FileToken otherwise — that way
|
||||
// callers still have a usable handle to whatever
|
||||
// state Drive ended up in.
|
||||
failedToken := token
|
||||
if failedToken == "" {
|
||||
failedToken = entry.FileToken
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
|
||||
uploaded++
|
||||
continue
|
||||
}
|
||||
|
||||
parentRel := drivePushParentRel(rel)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
|
||||
uploaded++
|
||||
}
|
||||
|
||||
// Skip the delete phase entirely on any upstream failure. The orphan
|
||||
// loop deletes by remote token and is unrecoverable; running it
|
||||
// after a failed upload risks deleting a file the partial upload
|
||||
// would have replaced on a successful re-run, leaving the tenant
|
||||
// in a worse state than where we started. Surface the skipped
|
||||
// delete as a hint in stderr so operators know the cleanup pass
|
||||
// is pending and can re-run after fixing the upload.
|
||||
if deleteRemote && uploadFailed {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n",
|
||||
failed)
|
||||
}
|
||||
if deleteRemote && !uploadFailed {
|
||||
// Stable iteration order so failures (and tests) are deterministic.
|
||||
remoteRelPaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
remoteRelPaths = append(remoteRelPaths, p)
|
||||
}
|
||||
sort.Strings(remoteRelPaths)
|
||||
|
||||
for _, rel := range remoteRelPaths {
|
||||
if _, ok := localFiles[rel]; ok {
|
||||
continue
|
||||
}
|
||||
entry := remoteFiles[rel]
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
deletedRemote++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"uploaded": uploaded,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
},
|
||||
"items": items,
|
||||
}, nil)
|
||||
// Bump the exit code on any item-level failure (upload, overwrite,
|
||||
// folder, or delete) so callers / scripts / agents can react. The
|
||||
// summary + items[] envelope was just written to stdout via Out(),
|
||||
// so ErrBare here only affects the exit code — the structured
|
||||
// per-item context is still in the stdout JSON.
|
||||
if failed > 0 {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// drivePushLocalFile records what we need to upload a local regular file:
|
||||
// a rel_path used for output and Drive layout, the cwd-relative path that
|
||||
// FileIO.Open accepts, the file size (drives single/multipart selection),
|
||||
// and the basename used as Drive's file_name.
|
||||
type drivePushLocalFile struct {
|
||||
RelPath string
|
||||
OpenPath string
|
||||
FileName string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// drivePushWalkLocal walks the canonical absolute root produced by
|
||||
// SafeInputPath. Same threat model as +pull/+status: the validated root
|
||||
// is not a symlink itself, and WalkDir's default policy (do not follow
|
||||
// child symlinks) keeps the traversal inside that canonical subtree, so
|
||||
// the OpenPath we hand to FileIO.Open stays inside cwd.
|
||||
//
|
||||
// Returns two views:
|
||||
// - files: rel_path → file metadata; drives the upload/skip/overwrite loop.
|
||||
// - dirs: every non-root directory rel_path encountered. Used to mirror
|
||||
// empty directories (which would otherwise be silently dropped because
|
||||
// the upload loop only iterates files); non-empty directories appear
|
||||
// here too but are harmless because drivePushEnsureFolder is cached.
|
||||
func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) {
|
||||
files := make(map[string]drivePushLocalFile)
|
||||
dirsSet := make(map[string]struct{})
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs
|
||||
// (depguard rule shortcuts-no-vfs). The walk root is the canonical
|
||||
// absolute path returned by validate.SafeInputPath, so it is no
|
||||
// longer a symlink itself, and WalkDir's default child-symlink
|
||||
// policy keeps the traversal inside the validated subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
// Skip the root itself ("."): that is --folder-token, already
|
||||
// the parent we mirror into, not a sub-folder we need to
|
||||
// create.
|
||||
if relSlash != "." {
|
||||
dirsSet[relSlash] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[relSlash] = drivePushLocalFile{
|
||||
RelPath: relSlash,
|
||||
OpenPath: relToCwd,
|
||||
FileName: filepath.Base(rel),
|
||||
Size: info.Size(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
dirs := make([]string, 0, len(dirsSet))
|
||||
for d := range dirsSet {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
// Shallow-first ordering ensures parents are created before children;
|
||||
// drivePushEnsureFolder also handles parent recursion on its own, but
|
||||
// emitting items[] in shallow-first order matches what users expect.
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return dirs[i] < dirs[j]
|
||||
})
|
||||
return files, dirs, nil
|
||||
}
|
||||
|
||||
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
|
||||
// folder identified by rootFolderToken) exists on Drive, creating any
|
||||
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
|
||||
// token of the deepest folder, suitable as parent_node for the upload.
|
||||
//
|
||||
// folderCache is shared with the caller so each segment is only created
|
||||
// once per push, and so subsequent uploads under the same sub-tree reuse
|
||||
// the freshly minted folder token without an extra round trip.
|
||||
func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) {
|
||||
if token, ok := folderCache[relDir]; ok {
|
||||
return token, nil
|
||||
}
|
||||
parentRel, name := drivePushSplitRel(relDir)
|
||||
parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_folder",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"name": name,
|
||||
"folder_token": parentToken,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := common.GetString(data, "token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
|
||||
}
|
||||
folderCache[relDir] = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// drivePushUploadFile uploads (or overwrites) a single local file. When
|
||||
// existingToken is non-empty, the request adds the file_token form field to
|
||||
// trigger overwrite-with-version semantics on the backend; the response is
|
||||
// expected to carry a non-empty `version`, which is propagated to the
|
||||
// caller for the items[].version field. When existingToken is empty, this
|
||||
// is a fresh upload under parentToken.
|
||||
//
|
||||
// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to
|
||||
// the three-step prepare/part/finish flow, which mirrors drive +upload's
|
||||
// existing multipart logic.
|
||||
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
|
||||
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
|
||||
// Multipart finish does not return version on the existing
|
||||
// /open-apis/drive/v1/files/upload_finish contract; surface an
|
||||
// empty version in that case rather than fabricating one. The
|
||||
// markdown +overwrite path has the same gap and is tracked for a
|
||||
// follow-up once the multipart endpoint exposes the field.
|
||||
return token, "", err
|
||||
}
|
||||
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
|
||||
}
|
||||
|
||||
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
f, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", file.FileName)
|
||||
fd.AddField("parent_type", driveUploadParentTypeExplorer)
|
||||
fd.AddField("parent_node", parentToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", file.Size))
|
||||
if existingToken != "" {
|
||||
// Overwrite mode: the backend interprets a non-empty file_token on
|
||||
// upload_all as "replace this file's content and bump its version",
|
||||
// matching the markdown +overwrite contract.
|
||||
fd.AddField("file_token", existingToken)
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", "", err
|
||||
}
|
||||
return "", "", output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
// Extract the token before the larkCode check: the backend can produce
|
||||
// a partial-success response (code != 0 alongside a non-empty
|
||||
// data.file_token) where bytes have already landed under that token.
|
||||
// Returning "" here would force the caller to fall back to
|
||||
// entry.FileToken and silently lose the token Drive actually used,
|
||||
// defeating the overwrite-error token-stability handling in Execute.
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
token := common.GetString(data, "file_token")
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
}
|
||||
if token == "" {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
// Some backends return the version under data_version; accept either
|
||||
// per the markdown +overwrite contract.
|
||||
version = common.GetString(data, "data_version")
|
||||
}
|
||||
if existingToken != "" && version == "" {
|
||||
// The protocol guarantees a non-empty version on overwrite. If the
|
||||
// deployed backend hasn't shipped the field yet we surface the gap
|
||||
// rather than report a phantom success — callers can downgrade to
|
||||
// --if-exists=skip in the meantime.
|
||||
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
||||
}
|
||||
return token, version, nil
|
||||
}
|
||||
|
||||
func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) {
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": file.FileName,
|
||||
"parent_type": driveUploadParentTypeExplorer,
|
||||
"parent_node": parentToken,
|
||||
"size": file.Size,
|
||||
}
|
||||
if existingToken != "" {
|
||||
prepareBody["file_token"] = existingToken
|
||||
}
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uploadID := common.GetString(prepareResult, "upload_id")
|
||||
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
|
||||
blockNum := int(common.GetFloat(prepareResult, "block_num"))
|
||||
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
uploadID, blockSize, blockNum)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
|
||||
common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum)
|
||||
|
||||
// Open the local file ONCE for the whole multipart loop. fileio.File
|
||||
// implements io.ReaderAt, so each block is a fresh
|
||||
// io.NewSectionReader over a shared fd — no need to reopen N times
|
||||
// (which is what drive +upload's existing multipart helper does and
|
||||
// what the original drive_push copy inherited; that pattern wastes
|
||||
// one Open + Close + path-validation per block).
|
||||
partFile, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer partFile.Close()
|
||||
|
||||
for seq := 0; seq < blockNum; seq++ {
|
||||
offset := int64(seq) * blockSize
|
||||
partSize := blockSize
|
||||
if remaining := file.Size - offset; partSize > remaining {
|
||||
partSize = remaining
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", partSize))
|
||||
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
|
||||
|
||||
apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if doErr != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(doErr, &exitErr) {
|
||||
return "", doErr
|
||||
}
|
||||
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
||||
}
|
||||
|
||||
var partResult map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
|
||||
}
|
||||
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
|
||||
msg, _ := partResult["msg"].(string)
|
||||
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := common.GetString(finishResult, "file_token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// drivePushDeleteFile deletes a single Drive file (type=file). Folders are
|
||||
// never reached here because --delete-remote only iterates the type=file
|
||||
// subset of the remote listing.
|
||||
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
|
||||
_, err := runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
|
||||
map[string]interface{}{"type": driveTypeFile},
|
||||
nil,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// drivePushParentRel returns the parent rel_path of rel ("" when the file
|
||||
// lives at the root). The local walker emits forward-slash rel_paths so
|
||||
// path.Dir is the right primitive here, not filepath.Dir.
|
||||
func drivePushParentRel(rel string) string {
|
||||
dir := path.Dir(rel)
|
||||
if dir == "." || dir == "/" {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// drivePushSplitRel splits a non-empty rel into (parent, basename), both
|
||||
// using forward slashes.
|
||||
func drivePushSplitRel(rel string) (string, string) {
|
||||
idx := strings.LastIndex(rel, "/")
|
||||
if idx < 0 {
|
||||
return "", rel
|
||||
}
|
||||
return rel[:idx], rel[idx+1:]
|
||||
}
|
||||
1197
shortcuts/drive/drive_push_test.go
Normal file
1197
shortcuts/drive/drive_push_test.go
Normal file
File diff suppressed because it is too large
Load Diff
265
shortcuts/drive/drive_status.go
Normal file
265
shortcuts/drive/drive_status.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveStatusEntry struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
}
|
||||
|
||||
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
|
||||
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
|
||||
//
|
||||
// Only Drive entries with type=file are compared; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
// equivalent local binary to hash against.
|
||||
//
|
||||
// SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any
|
||||
// path that resolves outside cwd, which keeps the local side bounded to the
|
||||
// caller's working directory.
|
||||
var DriveStatus = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+status",
|
||||
Description: "Compare a local directory with a Drive folder by content hash",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "Drive folder token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
// Path safety (absolute paths, traversal, symlink escape) is enforced
|
||||
// upfront by the framework helper so the error message references the
|
||||
// correct flag name; FileIO().Stat below would do the same check, but
|
||||
// surface --file in its hint.
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
// which closes the kernel-level escape route that filepath.Clean
|
||||
// alone misses: e.g. "link/.." string-cleans to "." but the kernel
|
||||
// resolves through link's target's parent, so a raw walk on the
|
||||
// user-supplied string can land outside cwd. Walking the canonical
|
||||
// root sidesteps that — and the matching cwd canonical lets each
|
||||
// absolute walk hit be converted to a cwd-relative path that
|
||||
// FileIO.Open's SafeInputPath check still accepts.
|
||||
//
|
||||
// Validate already ran SafeLocalFlagPath (with the proper flag
|
||||
// name in the error message), so a failure here is unexpected and
|
||||
// only possible under a Validate↔Execute race.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// +status only diffs binary content, so collapse the unified
|
||||
// listing to type=file. Online docs / shortcuts have no
|
||||
// hashable bytes and are intentionally absent from the diff
|
||||
// view (a docx living next to a same-named local file is a
|
||||
// known no-op).
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
for rel, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
paths := mergeStatusPaths(localHashes, remoteFiles)
|
||||
|
||||
var newLocal, newRemote, modified, unchanged []driveStatusEntry
|
||||
for _, relPath := range paths {
|
||||
localHash, hasLocal := localHashes[relPath]
|
||||
remoteToken, hasRemote := remoteFiles[relPath]
|
||||
switch {
|
||||
case hasLocal && !hasRemote:
|
||||
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
||||
case !hasLocal && hasRemote:
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
|
||||
default:
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
|
||||
if localHash == remoteHash {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
modified = append(modified, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
"unchanged": emptyIfNil(unchanged),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// walkLocalForStatus walks the canonical absolute root produced by
|
||||
// SafeInputPath. Using the canonical root keeps the kernel from
|
||||
// following any symlink hidden inside the user-supplied --local-dir
|
||||
// (e.g. "link/..", which filepath.Clean shrinks to "." but which OS
|
||||
// path resolution would resolve through the symlink target). For each
|
||||
// hit, we report rel_path relative to root for the JSON output, and
|
||||
// convert the absolute path to a cwd-relative form so FileIO.Open's
|
||||
// SafeInputPath check (which rejects absolute paths) still applies.
|
||||
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs.
|
||||
// The walk root is the canonical absolute path returned by
|
||||
// validate.SafeInputPath, so it is no longer a symlink itself, and
|
||||
// WalkDir's default policy (do not follow child symlinks) keeps the
|
||||
// traversal inside that canonical subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, err := hashLocalForStatus(runtime, relToCwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[filepath.ToSlash(rel)] = sum
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, resp.Body); err != nil {
|
||||
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func mergeStatusPaths(local, remote map[string]string) []string {
|
||||
seen := make(map[string]struct{}, len(local)+len(remote))
|
||||
for p := range local {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
for p := range remote {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func emptyIfNil(s []driveStatusEntry) []driveStatusEntry {
|
||||
if s == nil {
|
||||
return []driveStatusEntry{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
498
shortcuts/drive/drive_status_test.go
Normal file
498
shortcuts/drive/drive_status_test.go
Normal file
@@ -0,0 +1,498 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
|
||||
// against a real walk of the temp dir and a mocked Drive listing.
|
||||
func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
// Local layout:
|
||||
// local/a.txt — also on remote with different content → modified
|
||||
// local/b.txt — only local → new_local
|
||||
// local/sub/c.txt — also on remote with same content → unchanged
|
||||
// Remote-only:
|
||||
// d.txt → new_remote
|
||||
if err := os.MkdirAll("local/sub", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("aaa"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/b.txt", []byte("bbb"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/sub/c.txt", []byte("ccc"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile sub/c.txt: %v", err)
|
||||
}
|
||||
|
||||
// Root folder list — order matters: stubs match in registration order.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
|
||||
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"},
|
||||
// noise: an online doc and a shortcut should be ignored
|
||||
map[string]interface{}{"token": "tok_doc", "name": "ignored.docx", "type": "docx"},
|
||||
map[string]interface{}{"token": "tok_sc", "name": "ignored.lnk", "type": "shortcut"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Subfolder list
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=tok_sub",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Download a.txt: remote content differs from local "aaa" → modified.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: 200,
|
||||
Body: []byte("AAA"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
// Download c.txt: remote content matches local "ccc" → unchanged.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_c/download",
|
||||
Status: 200,
|
||||
Body: []byte("ccc"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
checks := []struct {
|
||||
bucket string
|
||||
path string
|
||||
token string
|
||||
}{
|
||||
{"new_local", "b.txt", ""},
|
||||
{"new_remote", "d.txt", "tok_d"},
|
||||
{"modified", "a.txt", "tok_a"},
|
||||
{"unchanged", "sub/c.txt", "tok_c"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(out, `"`+c.bucket+`":`) {
|
||||
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
|
||||
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
|
||||
}
|
||||
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
|
||||
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(out, "ignored.docx") || strings.Contains(out, "ignored.lnk") {
|
||||
t.Errorf("output should skip docx/shortcut entries\noutput: %s", out)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
|
||||
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
|
||||
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
|
||||
// (what the shared helper also accepts). If the shortcut had hard-coded
|
||||
// either field name, one of the two pages' files would be silently dropped
|
||||
// from the comparison and would land in the wrong bucket. Stub order is
|
||||
// significant: httpmock matches in registration order, and both stubs key on
|
||||
// the GET .../files URL — they pop in turn, so page 1's response (with the
|
||||
// continuation token) must be registered before page 2's terminator.
|
||||
func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
// Page 1: returns one file plus a continuation token via
|
||||
// next_page_token (the field Drive currently emits).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_p1", "name": "page1.txt", "type": "file"},
|
||||
},
|
||||
"has_more": true,
|
||||
"next_page_token": "cursor-page-2",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Page 2: returns the second file with has_more=false. This stub uses
|
||||
// page_token (the alternate spelling) to lock in that the shared
|
||||
// PaginationMeta helper accepts BOTH field names.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_p2", "name": "page2.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Both pages contributed to new_remote (local is empty).
|
||||
for _, want := range []string{
|
||||
`"rel_path": "page1.txt"`,
|
||||
`"file_token": "tok_p1"`,
|
||||
`"rel_path": "page2.txt"`,
|
||||
`"file_token": "tok_p2"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q (a page must have been silently dropped)\noutput: %s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "does-not-exist",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing local dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsLocalFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "not-a-dir.txt",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when --local-dir is a file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a directory") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for absolute --local-dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusRejectsEmptyFolderToken covers the Validate-stage required
|
||||
// check that runs before ResourceName: an empty --folder-token must surface
|
||||
// a structured FlagError referencing the flag name.
|
||||
func TestDriveStatusRejectsEmptyFolderToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty --folder-token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token") {
|
||||
t.Fatalf("error must reference --folder-token, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusDoesNotEscapeViaSymlinkParentRef is the regression for the
|
||||
// "link/.." escape: filepath.Clean string-shrinks "link/.." to ".", so a
|
||||
// raw walk on the user-supplied input can land on the kernel-resolved
|
||||
// path through link's target's parent — outside cwd. The fix is to walk
|
||||
// SafeInputPath's canonical absolute root instead of the raw input.
|
||||
//
|
||||
// Setup: an "escape" sibling directory contains a sentinel file; cwd
|
||||
// contains a "link" symlink pointing into that escape directory.
|
||||
// Calling +status with --local-dir "link/.." must not surface the
|
||||
// sentinel — the walk must stay inside cwd.
|
||||
func TestDriveStatusDoesNotEscapeViaSymlinkParentRef(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
// Sentinel lives outside cwd; the agent must never see it.
|
||||
escapeDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile secret: %v", err)
|
||||
}
|
||||
|
||||
// cwd has a symlink that points into the sentinel's parent.
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.Symlink(escapeDir, filepath.Join(cwdDir, "link")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
// A normal file inside cwd just to make the walk non-trivial.
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "ok.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile ok: %v", err)
|
||||
}
|
||||
|
||||
// Empty remote folder so any path that surfaces in the output
|
||||
// must have come from the local walk.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "link/..",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
|
||||
t.Fatalf("walk escaped via link/..: secret.txt leaked into output\noutput:\n%s", out)
|
||||
}
|
||||
// ok.txt is in cwd and must classify as new_local (no remote stub for it).
|
||||
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
|
||||
t.Fatalf("expected ok.txt in new_local, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusSkipsSymlinkInsideRoot pins down WalkDir's default policy
|
||||
// for symlinks discovered as child entries: they are reported with a
|
||||
// non-regular file mode and the callback skips them, so a symlink inside
|
||||
// the validated root pointing into an out-of-tree directory cannot leak
|
||||
// the target's contents.
|
||||
func TestDriveStatusSkipsSymlinkInsideRoot(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
// Sentinel sits outside cwd; a child symlink inside the walked root
|
||||
// points there. If the walker followed child symlinks (it must not),
|
||||
// the sentinel's name would surface in new_local.
|
||||
escapeDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile secret: %v", err)
|
||||
}
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "ok.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile ok: %v", err)
|
||||
}
|
||||
// Child-of-root symlink that resolves out of the validated subtree.
|
||||
if err := os.Symlink(escapeDir, filepath.Join("local", "sub", "escape")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
|
||||
t.Fatalf("walk followed child symlink and leaked sentinel:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
|
||||
t.Fatalf("expected ok.txt in new_local; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusSurvivesCircularSymlinkInsideRoot makes sure WalkDir
|
||||
// terminates even when a child symlink points back at one of its
|
||||
// ancestors. WalkDir's default policy already declines to follow child
|
||||
// symlinks; this test pins that contract for our caller.
|
||||
func TestDriveStatusSurvivesCircularSymlinkInsideRoot(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "sub", "real.txt"), []byte("real"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
// loop symlink: cwd/local/sub/loop -> cwd/local (an ancestor).
|
||||
loopTarget, err := filepath.Abs(filepath.Join("local"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs: %v", err)
|
||||
}
|
||||
if err := os.Symlink(loopTarget, filepath.Join("local", "sub", "loop")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If WalkDir followed the loop, this test would never finish; the
|
||||
// test runner's per-test timeout would surface that as a failure.
|
||||
err = mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rel_path": "sub/real.txt"`) {
|
||||
t.Fatalf("expected sub/real.txt in new_local; got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusRejectsMalformedFolderToken covers the ResourceName format
|
||||
// guard: a token with control characters (newline) must be rejected before
|
||||
// any API call is made.
|
||||
func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "tok\nwithnewline",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for malformed --folder-token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token") {
|
||||
t.Fatalf("error must reference --folder-token, got: %v", err)
|
||||
}
|
||||
}
|
||||
116
shortcuts/drive/list_remote.go
Normal file
116
shortcuts/drive/list_remote.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveListRemotePageSize = 200
|
||||
driveTypeFile = "file"
|
||||
driveTypeFolder = "folder"
|
||||
)
|
||||
|
||||
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
|
||||
// carries enough metadata for every shortcut that consumes the listing
|
||||
// to build its own per-shortcut view by filtering on Type.
|
||||
type driveRemoteEntry struct {
|
||||
// FileToken is the Drive token for this entry. For type=folder this
|
||||
// is the folder_token; for everything else it is the file_token.
|
||||
FileToken string
|
||||
// Type is the Drive entry kind verbatim from the API:
|
||||
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
|
||||
// "mindnote" | "slides" | "shortcut" | …
|
||||
Type string
|
||||
// RelPath is the entry's path relative to the listing root. Encoded
|
||||
// with "/" separators on every platform so it matches the rel_paths
|
||||
// produced by the shortcuts' local walkers.
|
||||
RelPath string
|
||||
}
|
||||
|
||||
// listRemoteFolder recursively lists folderToken under relBase and
|
||||
// returns one entry per Drive item, keyed by rel_path. Subfolders are
|
||||
// descended into and the folder's own entry is also recorded — callers
|
||||
// can reason about "this rel_path is occupied by a folder" without
|
||||
// re-listing.
|
||||
//
|
||||
// This is the shared backbone for the three sync-disk shortcuts. None
|
||||
// of them need every field at every call site, so each one filters
|
||||
// on Type:
|
||||
//
|
||||
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
|
||||
// to drive content-hash diffs against the local tree.
|
||||
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
|
||||
// download set, and the full key set (every rel_path) as the
|
||||
// guard for --delete-local.
|
||||
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
|
||||
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
|
||||
// for the create_folder cache.
|
||||
//
|
||||
// Pagination uses common.PaginationMeta, which accepts both
|
||||
// page_token and next_page_token — the Drive list endpoint has
|
||||
// historically returned the latter, but the helper future-proofs
|
||||
// against a backend rename.
|
||||
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
|
||||
out := make(map[string]driveRemoteEntry)
|
||||
pageToken := ""
|
||||
for {
|
||||
params := map[string]interface{}{
|
||||
"folder_token": folderToken,
|
||||
"page_size": fmt.Sprint(driveListRemotePageSize),
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawFiles, _ := result["files"].([]interface{})
|
||||
for _, item := range rawFiles {
|
||||
f, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fType := common.GetString(f, "type")
|
||||
fName := common.GetString(f, "name")
|
||||
fToken := common.GetString(f, "token")
|
||||
if fName == "" || fToken == "" {
|
||||
continue
|
||||
}
|
||||
rel := joinRelDrive(relBase, fName)
|
||||
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
|
||||
if fType == driveTypeFolder {
|
||||
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range sub {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
hasMore, nextToken := common.PaginationMeta(result)
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// joinRelDrive joins a rel_path base with an entry name using "/".
|
||||
// Empty base means the entry sits at the listing root. Mirrors the
|
||||
// behavior the per-shortcut helpers used to ship and keeps rel_paths
|
||||
// stable across +status / +pull / +push.
|
||||
func joinRelDrive(base, name string) string {
|
||||
if base == "" {
|
||||
return name
|
||||
}
|
||||
return base + "/" + name
|
||||
}
|
||||
@@ -18,6 +18,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveDelete,
|
||||
DriveStatus,
|
||||
DrivePush,
|
||||
DrivePull,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSearch,
|
||||
|
||||
@@ -21,6 +21,9 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+status",
|
||||
"+push",
|
||||
"+pull",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+search",
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
@@ -215,11 +216,24 @@ type PatchOp struct {
|
||||
Target AttachmentTarget `json:"target,omitempty"`
|
||||
SignatureID string `json:"signature_id,omitempty"`
|
||||
|
||||
// Calendar event fields, used by set_calendar. The raw ISO 8601 strings
|
||||
// are shown in dry-run output; the shortcut layer pre-builds the ICS
|
||||
// blob into CalendarICS below before Apply runs.
|
||||
EventSummary string `json:"event_summary,omitempty"`
|
||||
EventStart string `json:"event_start,omitempty"`
|
||||
EventEnd string `json:"event_end,omitempty"`
|
||||
EventLocation string `json:"event_location,omitempty"`
|
||||
|
||||
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
|
||||
// fetching and interpolating the signature. The patch layer uses this
|
||||
// pre-rendered content for insert_signature ops.
|
||||
RenderedSignatureHTML string `json:"-"`
|
||||
SignatureImages []SignatureImage `json:"-"`
|
||||
|
||||
// CalendarICS holds the pre-built RFC 5545 ICS blob for a set_calendar
|
||||
// op. Populated by the shortcut layer after the snapshot is parsed and
|
||||
// organizer/attendee addresses can be resolved. Not serialised.
|
||||
CalendarICS []byte `json:"-"`
|
||||
}
|
||||
|
||||
// SignatureImage holds pre-downloaded image data for signature inline images.
|
||||
@@ -327,6 +341,26 @@ func (op PatchOp) Validate() error {
|
||||
}
|
||||
case "remove_signature":
|
||||
// No required fields.
|
||||
case "set_calendar":
|
||||
if strings.TrimSpace(op.EventSummary) == "" {
|
||||
return fmt.Errorf("set_calendar requires event_summary")
|
||||
}
|
||||
if strings.TrimSpace(op.EventStart) == "" || strings.TrimSpace(op.EventEnd) == "" {
|
||||
return fmt.Errorf("set_calendar requires event_start and event_end")
|
||||
}
|
||||
start, err := parseISO8601(op.EventStart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set_calendar: event_start must be a valid ISO 8601 timestamp")
|
||||
}
|
||||
end, err := parseISO8601(op.EventEnd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set_calendar: event_end must be a valid ISO 8601 timestamp")
|
||||
}
|
||||
if !end.After(start) {
|
||||
return fmt.Errorf("set_calendar: event_end must be after event_start")
|
||||
}
|
||||
case "remove_calendar":
|
||||
// No required fields.
|
||||
default:
|
||||
return fmt.Errorf("unsupported op %q", op.Op)
|
||||
}
|
||||
@@ -400,3 +434,19 @@ func MustJSON(v interface{}) string {
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// parseISO8601 tries common ISO 8601 timestamp layouts, accepting both
|
||||
// with-seconds (RFC 3339) and without-seconds variants.
|
||||
func parseISO8601(s string) (time.Time, error) {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
|
||||
return insertSignatureOp(snapshot, op)
|
||||
case "remove_signature":
|
||||
return removeSignatureOp(snapshot)
|
||||
case "set_calendar":
|
||||
return applyCalendarSet(snapshot, op.CalendarICS)
|
||||
case "remove_calendar":
|
||||
return applyCalendarRemove(snapshot)
|
||||
default:
|
||||
return fmt.Errorf("unsupported patch op %q", op.Op)
|
||||
}
|
||||
|
||||
188
shortcuts/mail/draft/patch_calendar.go
Normal file
188
shortcuts/mail/draft/patch_calendar.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package draft
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const calendarMediaType = "text/calendar"
|
||||
|
||||
// applyCalendarSet installs or replaces the text/calendar MIME part in the
|
||||
// snapshot. The caller is expected to have pre-built icsData using the
|
||||
// snapshot's From/To/Cc addresses.
|
||||
func applyCalendarSet(snapshot *DraftSnapshot, icsData []byte) error {
|
||||
if len(icsData) == 0 {
|
||||
return fmt.Errorf("set_calendar: ICS data is empty (shortcut layer must pre-build it)")
|
||||
}
|
||||
setCalendarPart(snapshot, icsData)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyCalendarRemove strips the text/calendar part from the snapshot.
|
||||
// No-op if no calendar part exists.
|
||||
func applyCalendarRemove(snapshot *DraftSnapshot) error {
|
||||
removeCalendarPart(snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setCalendarPart places exactly one text/calendar part inside
|
||||
// multipart/alternative, matching the Feishu client behavior. Any existing
|
||||
// text/calendar parts elsewhere in the tree are removed first.
|
||||
func setCalendarPart(snapshot *DraftSnapshot, icsData []byte) {
|
||||
newPart := &Part{
|
||||
MediaType: calendarMediaType,
|
||||
MediaParams: map[string]string{"charset": "UTF-8", "method": "REQUEST"},
|
||||
Body: icsData,
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
if snapshot.Body == nil {
|
||||
snapshot.Body = newPart
|
||||
return
|
||||
}
|
||||
|
||||
// Remove all existing text/calendar parts from everywhere in the tree.
|
||||
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
||||
snapshot.Body = newPart
|
||||
return
|
||||
}
|
||||
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
||||
|
||||
// Place inside the existing multipart/alternative.
|
||||
if alt := FindPartByMediaType(snapshot.Body, "multipart/alternative"); alt != nil {
|
||||
alt.Children = append(alt.Children, newPart)
|
||||
alt.Dirty = true
|
||||
return
|
||||
}
|
||||
|
||||
// No multipart/alternative exists. If the body is a single leaf,
|
||||
// wrap it in multipart/alternative together with the calendar.
|
||||
if !snapshot.Body.IsMultipart() {
|
||||
original := *snapshot.Body
|
||||
// Reset all header-carrying fields so the serializer constructs a fresh
|
||||
// Content-Type from MediaType instead of reusing the stale leaf headers.
|
||||
snapshot.Body.Headers = nil
|
||||
snapshot.Body.MediaType = "multipart/alternative"
|
||||
snapshot.Body.MediaParams = nil
|
||||
snapshot.Body.ContentDisposition = ""
|
||||
snapshot.Body.ContentDispositionArg = nil
|
||||
snapshot.Body.ContentID = ""
|
||||
snapshot.Body.PartID = ""
|
||||
snapshot.Body.Body = nil
|
||||
snapshot.Body.TransferEncoding = ""
|
||||
snapshot.Body.RawEntity = nil
|
||||
snapshot.Body.Preamble = nil
|
||||
snapshot.Body.Epilogue = nil
|
||||
snapshot.Body.EncodingProblem = false
|
||||
snapshot.Body.Children = []*Part{&original, newPart}
|
||||
snapshot.Body.Dirty = true
|
||||
return
|
||||
}
|
||||
|
||||
// Multipart body without an alternative sub-part (e.g. multipart/mixed
|
||||
// with a text/html child). Find the first text/* child and wrap it in
|
||||
// a new multipart/alternative that also contains the calendar.
|
||||
for i, child := range snapshot.Body.Children {
|
||||
if child != nil && strings.HasPrefix(strings.ToLower(child.MediaType), "text/") {
|
||||
alt := &Part{
|
||||
MediaType: "multipart/alternative",
|
||||
Children: []*Part{child, newPart},
|
||||
Dirty: true,
|
||||
}
|
||||
snapshot.Body.Children[i] = alt
|
||||
snapshot.Body.Dirty = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: append to the root multipart container.
|
||||
snapshot.Body.Children = append(snapshot.Body.Children, newPart)
|
||||
snapshot.Body.Dirty = true
|
||||
}
|
||||
|
||||
func removeCalendarPart(snapshot *DraftSnapshot) {
|
||||
if snapshot.Body == nil {
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
||||
snapshot.Body = nil
|
||||
return
|
||||
}
|
||||
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
||||
}
|
||||
|
||||
// FindPartByMediaType walks the MIME tree and returns the first part with
|
||||
// the given media type, or nil when not found.
|
||||
func FindPartByMediaType(root *Part, mediaType string) *Part {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(root.MediaType, mediaType) {
|
||||
return root
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
if found := FindPartByMediaType(child, mediaType); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findAllPartsByMediaType walks the MIME tree and returns every part with
|
||||
// the given media type. Used in tests to assert tree contents.
|
||||
func findAllPartsByMediaType(root *Part, mediaType string) []*Part {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
var result []*Part
|
||||
if strings.EqualFold(root.MediaType, mediaType) {
|
||||
result = append(result, root)
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
result = append(result, findAllPartsByMediaType(child, mediaType)...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// removePartByMediaType removes the first part with the given media type from
|
||||
// the MIME tree. The parent is marked dirty when a removal happens.
|
||||
func removePartByMediaType(root *Part, mediaType string) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
for i, child := range root.Children {
|
||||
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
|
||||
root.Children = append(root.Children[:i], root.Children[i+1:]...)
|
||||
root.Dirty = true
|
||||
return
|
||||
}
|
||||
removePartByMediaType(child, mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
// removeAllPartsByMediaType removes every part with the given media type from
|
||||
// the MIME tree, at all nesting levels.
|
||||
func removeAllPartsByMediaType(root *Part, mediaType string) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
var kept []*Part
|
||||
removed := false
|
||||
for _, child := range root.Children {
|
||||
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, child)
|
||||
}
|
||||
if removed {
|
||||
root.Children = kept
|
||||
root.Dirty = true
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
removeAllPartsByMediaType(child, mediaType)
|
||||
}
|
||||
}
|
||||
429
shortcuts/mail/draft/patch_calendar_test.go
Normal file
429
shortcuts/mail/draft/patch_calendar_test.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package draft
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const fixtureCalData = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — validate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_ValidateRequiresSummary(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventStart: "2026-04-25T10:00+08:00", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_summary") {
|
||||
t.Errorf("expected event_summary error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateRequiresStartAndEnd(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "Meeting", EventStart: "2026-04-25T10:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_start and event_end") {
|
||||
t.Errorf("expected start/end error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateInvalidStartFormat(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "not-a-date", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_start") {
|
||||
t.Errorf("expected event_start error for bad format, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateInvalidEndFormat(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T10:00+08:00", EventEnd: "not-a-date"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_end") {
|
||||
t.Errorf("expected event_end error for bad format, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateEndNotAfterStart(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T11:00+08:00", EventEnd: "2026-04-25T10:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "after") {
|
||||
t.Errorf("expected end-after-start error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateOK(t *testing.T) {
|
||||
err := PatchOp{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "Meeting",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
}.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — Apply adds text/calendar part when none exists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_AddsCalendarPartToHTMLDraft(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "Meeting",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
CalendarICS: []byte(fixtureCalData),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
|
||||
if part == nil {
|
||||
t.Fatal("text/calendar part not added to draft")
|
||||
}
|
||||
if string(part.Body) != fixtureCalData {
|
||||
t.Errorf("calendar part body mismatch: got %q", part.Body)
|
||||
}
|
||||
if part.MediaParams["method"] != "REQUEST" {
|
||||
t.Errorf("calendar part missing method=REQUEST in MediaParams: %v", part.MediaParams)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — Apply replaces existing text/calendar part
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_ReplacesExistingCalendarPart(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="b1"
|
||||
|
||||
--b1
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>
|
||||
--b1
|
||||
Content-Type: text/calendar; charset=UTF-8
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
SUMMARY:OLD
|
||||
END:VCALENDAR
|
||||
--b1--`)
|
||||
|
||||
newICS := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "NEW",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
CalendarICS: newICS,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
|
||||
if part == nil {
|
||||
t.Fatal("text/calendar part missing")
|
||||
}
|
||||
if !strings.Contains(string(part.Body), "SUMMARY:NEW") {
|
||||
t.Errorf("expected new SUMMARY, got %q", part.Body)
|
||||
}
|
||||
if strings.Contains(string(part.Body), "SUMMARY:OLD") {
|
||||
t.Errorf("old SUMMARY not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — Apply requires pre-built ICS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_EmptyICSIsError(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "Meeting",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
// CalendarICS intentionally nil — simulates missing pre-process.
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CalendarICS")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ICS data is empty") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// remove_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRemoveCalendar_StripsCalendarPart(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="b1"
|
||||
|
||||
--b1
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>
|
||||
--b1
|
||||
Content-Type: text/calendar; charset=UTF-8
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
END:VCALENDAR
|
||||
--b1--`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_calendar"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
if part := FindPartByMediaType(snapshot.Body, calendarMediaType); part != nil {
|
||||
t.Errorf("text/calendar part should be removed, but still found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCalendar_NoOpWhenAbsent(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Plain
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_calendar"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
// Body remains intact.
|
||||
if snapshot.Body == nil {
|
||||
t.Fatal("body unexpectedly nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal MIME helpers (coverage)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindPartByMediaType_CaseInsensitive(t *testing.T) {
|
||||
root := &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "TEXT/Calendar"},
|
||||
},
|
||||
}
|
||||
got := FindPartByMediaType(root, "text/calendar")
|
||||
if got == nil {
|
||||
t.Fatal("expected to find part despite case mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovePartByMediaType_MarksParentDirty(t *testing.T) {
|
||||
root := &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "text/calendar"},
|
||||
{MediaType: "text/html"},
|
||||
},
|
||||
}
|
||||
removePartByMediaType(root, "text/calendar")
|
||||
if len(root.Children) != 1 {
|
||||
t.Fatalf("expected 1 remaining child, got %d", len(root.Children))
|
||||
}
|
||||
if !root.Dirty {
|
||||
t.Error("parent not marked dirty after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_CollapsesToOneInsideAlternative(t *testing.T) {
|
||||
// Feishu client creates two text/calendar copies: one inside
|
||||
// multipart/alternative and one as an inline attachment in
|
||||
// multipart/mixed. set_calendar must collapse them to a single
|
||||
// copy inside multipart/alternative.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="outer"
|
||||
|
||||
--outer
|
||||
Content-Type: multipart/alternative; boundary="inner"
|
||||
|
||||
--inner
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>
|
||||
--inner
|
||||
Content-Type: text/calendar; charset=UTF-8
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
SUMMARY:OLD
|
||||
END:VCALENDAR
|
||||
--inner--
|
||||
--outer
|
||||
Content-Type: text/calendar; charset=UTF-8; name="invite.ics"
|
||||
Content-Id: <invite.ics>
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
SUMMARY:OLD
|
||||
END:VCALENDAR
|
||||
--outer--`)
|
||||
|
||||
newICS := []byte("BEGIN:VCALENDAR\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "NEW",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
CalendarICS: newICS,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
// Exactly one text/calendar part should remain, inside alternative.
|
||||
parts := findAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("expected 1 text/calendar part, got %d", len(parts))
|
||||
}
|
||||
if !strings.Contains(string(parts[0].Body), "SUMMARY:NEW") {
|
||||
t.Errorf("expected SUMMARY:NEW, got %q", parts[0].Body)
|
||||
}
|
||||
|
||||
// The calendar part must be a child of multipart/alternative.
|
||||
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
|
||||
if alt == nil {
|
||||
t.Fatal("multipart/alternative not found")
|
||||
}
|
||||
found := false
|
||||
for _, child := range alt.Children {
|
||||
if strings.EqualFold(child.MediaType, calendarMediaType) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("text/calendar part not inside multipart/alternative")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCalendar_RootLevelCalendarBody(t *testing.T) {
|
||||
// When the snapshot body is itself a text/calendar leaf (no multipart
|
||||
// wrapper), removeCalendarPart must nil out snapshot.Body rather than
|
||||
// trying to remove it from a parent's children slice.
|
||||
snapshot := &DraftSnapshot{
|
||||
Body: &Part{
|
||||
MediaType: "text/calendar",
|
||||
Body: []byte(fixtureCalData),
|
||||
},
|
||||
}
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_calendar"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if snapshot.Body != nil {
|
||||
t.Errorf("snapshot.Body should be nil after removing root-level text/calendar, got %+v", snapshot.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendarPart_OnNilBodyCreatesLeaf(t *testing.T) {
|
||||
snapshot := &DraftSnapshot{}
|
||||
setCalendarPart(snapshot, []byte(fixtureCalData))
|
||||
if snapshot.Body == nil {
|
||||
t.Fatal("body should be created")
|
||||
}
|
||||
if !strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
||||
t.Errorf("expected %s leaf, got %s", calendarMediaType, snapshot.Body.MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendarPart_MixedWithoutAlternativeWrapsTextChild(t *testing.T) {
|
||||
// multipart/mixed with a text/html child but no alternative sub-part.
|
||||
// setCalendarPart should wrap the text/html in a new alternative.
|
||||
snapshot := &DraftSnapshot{
|
||||
Body: &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "text/html", Body: []byte("<p>Hi</p>")},
|
||||
{MediaType: "application/pdf", Body: []byte("pdf-data")},
|
||||
},
|
||||
},
|
||||
}
|
||||
setCalendarPart(snapshot, []byte(fixtureCalData))
|
||||
|
||||
if snapshot.Body.MediaType != "multipart/mixed" {
|
||||
t.Fatalf("root should stay multipart/mixed, got %s", snapshot.Body.MediaType)
|
||||
}
|
||||
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
|
||||
if alt == nil {
|
||||
t.Fatal("expected a multipart/alternative child to be created")
|
||||
}
|
||||
if len(alt.Children) != 2 {
|
||||
t.Fatalf("alternative should have 2 children, got %d", len(alt.Children))
|
||||
}
|
||||
if !strings.EqualFold(alt.Children[0].MediaType, "text/html") {
|
||||
t.Errorf("first alternative child should be text/html, got %s", alt.Children[0].MediaType)
|
||||
}
|
||||
if !strings.EqualFold(alt.Children[1].MediaType, calendarMediaType) {
|
||||
t.Errorf("second alternative child should be text/calendar, got %s", alt.Children[1].MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendarPart_FallbackAppendsToMultipart(t *testing.T) {
|
||||
// multipart/mixed with only non-text children (no text/* to wrap).
|
||||
snapshot := &DraftSnapshot{
|
||||
Body: &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "application/pdf", Body: []byte("pdf-data")},
|
||||
},
|
||||
},
|
||||
}
|
||||
setCalendarPart(snapshot, []byte(fixtureCalData))
|
||||
|
||||
found := false
|
||||
for _, child := range snapshot.Body.Children {
|
||||
if strings.EqualFold(child.MediaType, calendarMediaType) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("text/calendar should be appended as fallback child")
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,9 @@ func (b Builder) HTMLBody(body []byte) Builder {
|
||||
}
|
||||
|
||||
// CalendarBody sets the text/calendar body (e.g. for meeting invitations).
|
||||
// May be combined with TextBody and/or HTMLBody; the resulting parts are wrapped
|
||||
// in multipart/alternative.
|
||||
// When combined with TextBody or HTMLBody, the calendar part is placed inside
|
||||
// multipart/alternative alongside the body parts, matching the Feishu client
|
||||
// convention for calendar invitation emails.
|
||||
func (b Builder) CalendarBody(body []byte) Builder {
|
||||
b.calendarBody = body
|
||||
return b
|
||||
@@ -731,6 +732,9 @@ func (b Builder) Build() ([]byte, error) {
|
||||
// ── Body ───────────────────────────────────────────────────────────────────
|
||||
// Full MIME hierarchy (outer layers only present when needed):
|
||||
// multipart/mixed → multipart/related → multipart/alternative → body parts
|
||||
//
|
||||
// text/calendar lives inside multipart/alternative as an alternative
|
||||
// representation of the message body, matching the Feishu client behavior.
|
||||
if len(b.attachments) > 0 {
|
||||
outerB := newBoundary()
|
||||
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+outerB)
|
||||
@@ -809,27 +813,27 @@ func writePrimaryBody(buf *bytes.Buffer, b Builder) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeAlternativeOrSingleBody writes the text body block.
|
||||
// If multiple body types (text/plain, text/html, text/calendar) are present,
|
||||
// they are wrapped in multipart/alternative. Otherwise a single part is written.
|
||||
// writeAlternativeOrSingleBody writes the body block. When multiple content
|
||||
// types coexist (text/plain, text/html, text/calendar), they are wrapped in
|
||||
// multipart/alternative. text/calendar lives inside alternative as an
|
||||
// alternative representation, matching the Feishu client behavior.
|
||||
func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
|
||||
hasText := len(b.textBody) > 0
|
||||
hasHTML := len(b.htmlBody) > 0
|
||||
hasCal := len(b.calendarBody) > 0
|
||||
|
||||
bodyCount := 0
|
||||
partCount := 0
|
||||
if hasText {
|
||||
bodyCount++
|
||||
partCount++
|
||||
}
|
||||
if hasHTML {
|
||||
bodyCount++
|
||||
partCount++
|
||||
}
|
||||
if hasCal {
|
||||
bodyCount++
|
||||
partCount++
|
||||
}
|
||||
|
||||
switch {
|
||||
case bodyCount > 1:
|
||||
if partCount > 1 {
|
||||
boundary := newBoundary()
|
||||
writeHeader(buf, "Content-Type", "multipart/alternative; boundary="+boundary)
|
||||
buf.WriteByte('\n')
|
||||
@@ -840,15 +844,15 @@ func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
|
||||
writeBodyPart(buf, boundary, "text/html", b.htmlBody)
|
||||
}
|
||||
if hasCal {
|
||||
writeBodyPart(buf, boundary, "text/calendar", b.calendarBody)
|
||||
fmt.Fprintf(buf, "--%s\n", boundary)
|
||||
writeCalendarPart(buf, b.calendarBody)
|
||||
}
|
||||
fmt.Fprintf(buf, "--%s--\n", boundary)
|
||||
case hasHTML:
|
||||
} else if hasHTML {
|
||||
writeSingleBodyPartHeaders(buf, "text/html", b.htmlBody)
|
||||
case hasCal:
|
||||
writeSingleBodyPartHeaders(buf, "text/calendar", b.calendarBody)
|
||||
default:
|
||||
// text/plain (also handles empty body)
|
||||
} else if hasCal {
|
||||
writeCalendarPart(buf, b.calendarBody)
|
||||
} else {
|
||||
writeSingleBodyPartHeaders(buf, "text/plain", b.textBody)
|
||||
}
|
||||
}
|
||||
@@ -992,6 +996,35 @@ func writeSingleBodyPartHeaders(buf *bytes.Buffer, ct string, body []byte) {
|
||||
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
|
||||
}
|
||||
|
||||
// writeCalendarPart writes the text/calendar MIME part. The method= parameter
|
||||
// is derived from the METHOD property in the ICS body (defaulting to REQUEST
|
||||
// when absent) so that passthrough ICS with METHOD:CANCEL or METHOD:REPLY
|
||||
// produce a Content-Type that matches the body.
|
||||
func writeCalendarPart(buf *bytes.Buffer, body []byte) {
|
||||
method := extractICSMethod(body)
|
||||
if method == "" {
|
||||
method = "REQUEST"
|
||||
}
|
||||
cte := selectCTE(body)
|
||||
fmt.Fprintf(buf, "Content-Type: text/calendar; method=%s; charset=UTF-8\n", method)
|
||||
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
|
||||
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
// extractICSMethod scans the ICS body for the top-level METHOD property and
|
||||
// returns its value (e.g. "REQUEST", "CANCEL", "REPLY"). Returns "" when the
|
||||
// property is absent so callers can apply their own default.
|
||||
func extractICSMethod(body []byte) string {
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(strings.ToUpper(line), "METHOD:") {
|
||||
return strings.TrimSpace(line[7:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeAttachmentPart writes a MIME attachment part.
|
||||
// Body is always base64 (StdEncoding), written in 76-character lines per RFC 2045.
|
||||
func writeAttachmentPart(buf *bytes.Buffer, att attachment) {
|
||||
|
||||
@@ -678,6 +678,8 @@ func TestBuild_CalendarWithText(t *testing.T) {
|
||||
}
|
||||
eml := string(raw)
|
||||
|
||||
// text/calendar lives inside multipart/alternative as an alternative
|
||||
// representation of the body, matching Feishu client behavior.
|
||||
if !strings.Contains(eml, "multipart/alternative") {
|
||||
t.Errorf("expected multipart/alternative for text+calendar:\n%s", eml)
|
||||
}
|
||||
@@ -1359,3 +1361,35 @@ func TestHeaderValueTabAllowed(t *testing.T) {
|
||||
t.Errorf("Header with tab in value: expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCalendarPart_MethodFromBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ics string
|
||||
wantCT string
|
||||
}{
|
||||
{"request", "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
|
||||
{"cancel", "BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nEND:VCALENDAR\r\n", "method=CANCEL"},
|
||||
{"reply", "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nEND:VCALENDAR\r\n", "method=REPLY"},
|
||||
{"no method defaults to REQUEST", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
eml, err := New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("Test").
|
||||
Date(fixedDate).
|
||||
MessageID("test-method@x").
|
||||
HTMLBody([]byte("<p>hi</p>")).
|
||||
CalendarBody([]byte(tc.ics)).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(eml), tc.wantCT) {
|
||||
t.Errorf("expected Content-Type to contain %q\n%s", tc.wantCT, eml)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
"github.com/larksuite/cli/shortcuts/mail/ics"
|
||||
)
|
||||
|
||||
// hintIdentityFirst prints a one-line tip to stderr for read-only mail shortcuts
|
||||
@@ -184,6 +185,15 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
|
||||
"attachments[].attachment_type": "Attachment type. Values: 1 = normal, 2 = large attachment",
|
||||
"attachments[].is_inline": "true = inline image, false = regular attachment",
|
||||
"attachments[].cid": "Content-ID for inline images (maps to <img src='cid:...'>)",
|
||||
"calendar_event": "Parsed calendar invitation; present when the email contains a text/calendar part",
|
||||
"calendar_event.method": "iTIP method, e.g. REQUEST, CANCEL, REPLY",
|
||||
"calendar_event.uid": "Globally unique event identifier (UID property)",
|
||||
"calendar_event.summary": "Event title (SUMMARY property)",
|
||||
"calendar_event.start": "Event start time in RFC 3339 / ISO 8601 format (UTC)",
|
||||
"calendar_event.end": "Event end time in RFC 3339 / ISO 8601 format (UTC)",
|
||||
"calendar_event.location": "Event location string; omitted when not set",
|
||||
"calendar_event.organizer": "Organizer email address",
|
||||
"calendar_event.attendees": "List of attendee email addresses",
|
||||
},
|
||||
"thread_extra_fields": map[string]string{
|
||||
"thread_id": "Thread ID",
|
||||
@@ -1199,11 +1209,23 @@ type normalizedMessageForCompose struct {
|
||||
BodyPlainText string `json:"body_plain_text"`
|
||||
BodyPreview string `json:"body_preview"`
|
||||
BodyHTML string `json:"body_html,omitempty"`
|
||||
CalendarEvent *calendarEventOutput `json:"calendar_event,omitempty"`
|
||||
Attachments []mailAttachmentOutput `json:"attachments"`
|
||||
Images []mailImageOutput `json:"images"`
|
||||
Warnings []warningEntry `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type calendarEventOutput struct {
|
||||
Method string `json:"method,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Organizer string `json:"organizer,omitempty"`
|
||||
Attendees []string `json:"attendees,omitempty"`
|
||||
}
|
||||
|
||||
// fetchAttachmentURLs fetches download URLs for the given attachment IDs in batches of 20.
|
||||
// List params are embedded directly in the URL (SDK workaround for repeated query params).
|
||||
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
|
||||
@@ -1349,6 +1371,9 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
|
||||
if html && normalized.BodyHTML != "" {
|
||||
out["body_html"] = normalized.BodyHTML
|
||||
}
|
||||
if normalized.CalendarEvent != nil {
|
||||
out["calendar_event"] = normalized.CalendarEvent
|
||||
}
|
||||
out["attachments"] = buildPublicAttachments(msg)
|
||||
|
||||
return out
|
||||
@@ -1458,6 +1483,29 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
|
||||
out.BodyHTML = decodeBase64URL(strVal(msg["body_html"]))
|
||||
}
|
||||
|
||||
// Calendar event
|
||||
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
|
||||
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
|
||||
if parsed := ics.ParseEvent(decoded); parsed != nil {
|
||||
ce := &calendarEventOutput{
|
||||
Method: parsed.Method,
|
||||
UID: parsed.UID,
|
||||
Summary: parsed.Summary,
|
||||
Location: parsed.Location,
|
||||
Organizer: parsed.Organizer,
|
||||
Attendees: parsed.Attendees,
|
||||
}
|
||||
if !parsed.Start.IsZero() {
|
||||
ce.Start = parsed.Start.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !parsed.End.IsZero() {
|
||||
ce.End = parsed.End.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out.CalendarEvent = ce
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
attachments := make([]mailAttachmentOutput, 0)
|
||||
images := make([]mailImageOutput, 0)
|
||||
@@ -1568,6 +1616,7 @@ type composeSourceMessage struct {
|
||||
ForwardAttachments []forwardSourceAttachment
|
||||
InlineImages []inlineSourcePart
|
||||
FailedAttachmentIDs map[string]bool
|
||||
OriginalCalendarICS []byte // raw ICS bytes from body_calendar (for forward passthrough)
|
||||
}
|
||||
|
||||
// fetchComposeSourceMessage loads a message via the +message pipeline and converts it
|
||||
@@ -1577,6 +1626,12 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
|
||||
if err != nil {
|
||||
return composeSourceMessage{}, err
|
||||
}
|
||||
var originalCalICS []byte
|
||||
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
|
||||
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
|
||||
originalCalICS = []byte(decoded)
|
||||
}
|
||||
}
|
||||
attIDs := extractAttachmentIDs(msg)
|
||||
urlMap, warnings := fetchAttachmentURLs(runtime, mailboxID, messageID, attIDs)
|
||||
failedIDs := make(map[string]bool)
|
||||
@@ -1592,6 +1647,7 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
|
||||
ForwardAttachments: toForwardSourceAttachments(out),
|
||||
InlineImages: toInlineSourceParts(out),
|
||||
FailedAttachmentIDs: failedIDs,
|
||||
OriginalCalendarICS: originalCalICS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2252,6 +2308,21 @@ func inlineSpecFilePaths(specs []InlineSpec) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// validateEventSendTimeExclusion checks that --send-time and --event-* are not
|
||||
// used together. This is enforced here (in Validate, before Execute) because the
|
||||
// Shortcut framework does not expose a cobra-level hook for MarkFlagsMutuallyExclusive.
|
||||
func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("send-time") == "" {
|
||||
return nil
|
||||
}
|
||||
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
|
||||
if runtime.Str(f) != "" {
|
||||
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
|
||||
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
|
||||
func validateSendTime(runtime *common.RuntimeContext) error {
|
||||
@@ -2391,3 +2462,143 @@ func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFl
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCalendarBodyFromArgs builds ICS from explicit string arguments (for draft-edit).
|
||||
// Callers are expected to have pre-validated startStr/endStr via parseEventTimeRange;
|
||||
// parse errors are silently ignored here and produce a zero-time DTSTART/DTEND.
|
||||
func buildCalendarBodyFromArgs(summary, startStr, endStr, location, senderEmail, toAddrs, ccAddrs string) []byte {
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
start, _ := parseISO8601(startStr)
|
||||
end, _ := parseISO8601(endStr)
|
||||
|
||||
var attendees []ics.Address
|
||||
for _, addr := range parseNetAddrs(toAddrs) {
|
||||
if addr.Address != "" {
|
||||
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
|
||||
}
|
||||
}
|
||||
for _, addr := range parseNetAddrs(ccAddrs) {
|
||||
if addr.Address != "" {
|
||||
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
|
||||
}
|
||||
}
|
||||
|
||||
return ics.Build(ics.Event{
|
||||
Summary: summary,
|
||||
Location: location,
|
||||
Start: start,
|
||||
End: end,
|
||||
Organizer: ics.Address{Email: senderEmail},
|
||||
Attendees: attendees,
|
||||
})
|
||||
}
|
||||
|
||||
// joinAddresses joins draft Address list into comma-separated string.
|
||||
func joinAddresses(addrs []draftpkg.Address) string {
|
||||
if len(addrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
parts[i] = a.Address
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// Calendar event flag definitions, shared by all compose shortcuts.
|
||||
// Declared as individual vars (like priorityFlag and signatureFlag) so
|
||||
// callers can list them explicitly in their Flags slice without relying
|
||||
// on slice-index access.
|
||||
var (
|
||||
eventSummaryFlag = common.Flag{Name: "event-summary", Desc: "Calendar event title. Setting this enables calendar invitation mode."}
|
||||
eventStartFlag = common.Flag{Name: "event-start", Desc: "Event start time (ISO 8601, e.g. 2026-04-20T14:00+08:00). Required when --event-summary is set."}
|
||||
eventEndFlag = common.Flag{Name: "event-end", Desc: "Event end time (ISO 8601). Required when --event-summary is set."}
|
||||
eventLocationFlag = common.Flag{Name: "event-location", Desc: "Event location (optional)."}
|
||||
)
|
||||
|
||||
// validateEventFlags checks that --event-summary, --event-start, --event-end are either all set or all empty.
|
||||
func validateEventFlags(runtime *common.RuntimeContext) error {
|
||||
summary := runtime.Str("event-summary")
|
||||
start := runtime.Str("event-start")
|
||||
end := runtime.Str("event-end")
|
||||
location := runtime.Str("event-location")
|
||||
|
||||
hasAny := summary != "" || start != "" || end != "" || location != ""
|
||||
hasAll := summary != "" && start != "" && end != ""
|
||||
|
||||
if hasAny && !hasAll {
|
||||
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
|
||||
}
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
if _, _, err := parseEventTimeRange(start, end); err != nil {
|
||||
return prefixEventRangeError("--event-", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseEventTimeRange parses start/end ISO 8601 strings and verifies that
|
||||
// end is strictly after start. Shared by validateEventFlags (compose path)
|
||||
// and buildDraftEditPatch (draft-edit path) so the rules stay in one place.
|
||||
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
|
||||
startT, err := parseISO8601(start)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
|
||||
}
|
||||
endT, err := parseISO8601(end)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
|
||||
}
|
||||
if !endT.After(startT) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
|
||||
}
|
||||
return startT, endT, nil
|
||||
}
|
||||
|
||||
// prefixEventRangeError rewrites parseEventTimeRange's "start:" / "end:"
|
||||
// error with the caller's flag-name prefix so users see the exact flag
|
||||
// that caused the failure.
|
||||
func prefixEventRangeError(flagPrefix string, err error) error {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.HasPrefix(msg, "start: "):
|
||||
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
|
||||
case strings.HasPrefix(msg, "end: "):
|
||||
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// parseISO8601 parses common ISO 8601 time formats.
|
||||
func parseISO8601(s string) (time.Time, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
|
||||
}
|
||||
|
||||
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
|
||||
// Returns nil if --event-summary is not set.
|
||||
func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAddrs, ccAddrs string) []byte {
|
||||
return buildCalendarBodyFromArgs(
|
||||
runtime.Str("event-summary"),
|
||||
runtime.Str("event-start"),
|
||||
runtime.Str("event-end"),
|
||||
runtime.Str("event-location"),
|
||||
senderEmail, toAddrs, ccAddrs,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1085,7 +1085,39 @@ func TestValidateSendTime_Valid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePriority verifies parse priority.
|
||||
func TestValidateEventSendTimeExclusion(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
cases := []struct {
|
||||
name string
|
||||
eventFlag string
|
||||
eventVal string
|
||||
}{
|
||||
{"event-summary triggers exclusion", "event-summary", "Team meeting"},
|
||||
{"event-start triggers exclusion", "event-start", "2026-05-01T10:00+08:00"},
|
||||
{"event-end triggers exclusion", "event-end", "2026-05-01T11:00+08:00"},
|
||||
{"event-location triggers exclusion", "event-location", "Room 5F"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("send-time", "", "")
|
||||
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
|
||||
cmd.Flags().String(f, "", "")
|
||||
}
|
||||
_ = cmd.Flags().Set("send-time", future)
|
||||
_ = cmd.Flags().Set(tc.eventFlag, tc.eventVal)
|
||||
rt := &common.RuntimeContext{Cmd: cmd}
|
||||
err := validateEventSendTimeExclusion(rt)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when --send-time and --%s are both set", tc.eventFlag)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--event-*") {
|
||||
t.Errorf("expected error to mention --event-*, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1334,7 +1366,6 @@ func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.Runtime
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestRequireSenderForRequestReceipt verifies require sender for request receipt.
|
||||
func TestRequireSenderForRequestReceipt(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1365,7 +1396,6 @@ func TestRequireSenderForRequestReceipt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellQuoteForHint verifies shell quote for hint.
|
||||
func TestShellQuoteForHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1391,7 +1421,6 @@ func TestShellQuoteForHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeForSingleLine verifies sanitize for single line.
|
||||
func TestSanitizeForSingleLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1415,7 +1444,6 @@ func TestSanitizeForSingleLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHeaderAddress verifies validate header address.
|
||||
func TestValidateHeaderAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1447,3 +1475,199 @@ func TestValidateHeaderAddress(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseEventTimeRange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseEventTimeRange_OK(t *testing.T) {
|
||||
s, e, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !e.After(s) {
|
||||
t.Errorf("end should be after start; got start=%v end=%v", s, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_EndBeforeStart(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("2026-04-25T15:00+08:00", "2026-04-25T14:00+08:00")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when end < start")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "end time must be after start time") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_EndEqualsStart(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T14:00+08:00")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when end == start (zero duration)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_InvalidStart(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("not-a-time", "2026-04-25T15:00+08:00")
|
||||
if err == nil || !strings.Contains(err.Error(), "start: invalid ISO 8601") {
|
||||
t.Errorf("expected start parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "not-a-time")
|
||||
if err == nil || !strings.Contains(err.Error(), "end: invalid ISO 8601") {
|
||||
t.Errorf("expected end parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixEventRangeError(t *testing.T) {
|
||||
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
|
||||
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
|
||||
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
// Non-prefixed error passes through unchanged.
|
||||
other := fmt.Errorf("end time must be after start time")
|
||||
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateEventFlags (runtime-backed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newEventFlagsRuntime(t *testing.T, summary, start, end string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("event-summary", "", "")
|
||||
cmd.Flags().String("event-start", "", "")
|
||||
cmd.Flags().String("event-end", "", "")
|
||||
cmd.Flags().String("event-location", "", "")
|
||||
if summary != "" {
|
||||
_ = cmd.Flags().Set("event-summary", summary)
|
||||
}
|
||||
if start != "" {
|
||||
_ = cmd.Flags().Set("event-start", start)
|
||||
}
|
||||
if end != "" {
|
||||
_ = cmd.Flags().Set("event-end", end)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_AllEmptyOK(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "", "", "")
|
||||
if err := validateEventFlags(rt); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_AllSetOK(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00")
|
||||
if err := validateEventFlags(rt); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_PartialRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
summary string
|
||||
start string
|
||||
end string
|
||||
}{
|
||||
{"only_summary", "Meeting", "", ""},
|
||||
{"only_start", "", "2026-04-25T10:00+08:00", ""},
|
||||
{"only_end", "", "", "2026-04-25T11:00+08:00"},
|
||||
{"missing_end", "Meeting", "2026-04-25T10:00+08:00", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, tc.summary, tc.start, tc.end)
|
||||
err := validateEventFlags(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "must all be provided together") {
|
||||
t.Errorf("expected 'all together' error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_EndBeforeStartRejected(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T11:00+08:00", "2026-04-25T10:00+08:00")
|
||||
err := validateEventFlags(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "after start") {
|
||||
t.Errorf("expected end-after-start error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_InvalidTimeFormatRejected(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "Meeting", "not-a-time", "2026-04-25T11:00+08:00")
|
||||
err := validateEventFlags(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--event-start") {
|
||||
t.Errorf("expected --event-start error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCalendarBodyFromArgs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildCalendarBodyFromArgs_EmptySummaryReturnsNil(t *testing.T) {
|
||||
got := buildCalendarBodyFromArgs("", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for empty summary, got %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarBodyFromArgs_IncludesSummaryAndAddresses(t *testing.T) {
|
||||
got := buildCalendarBodyFromArgs(
|
||||
"Product Review",
|
||||
"2026-04-25T14:00+08:00",
|
||||
"2026-04-25T15:00+08:00",
|
||||
"5F Room",
|
||||
"sender@example.com",
|
||||
"a@example.com,b@example.com",
|
||||
"c@example.com",
|
||||
)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil ICS bytes")
|
||||
}
|
||||
s := string(got)
|
||||
checks := []string{
|
||||
"BEGIN:VCALENDAR",
|
||||
"SUMMARY:Product Review",
|
||||
"LOCATION:5F Room",
|
||||
"sender@example.com",
|
||||
"a@example.com",
|
||||
"b@example.com",
|
||||
"c@example.com",
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in generated ICS:\n%s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarBodyFromArgs_NoCcWorks(t *testing.T) {
|
||||
got := buildCalendarBodyFromArgs(
|
||||
"Meeting",
|
||||
"2026-04-25T10:00+08:00",
|
||||
"2026-04-25T11:00+08:00",
|
||||
"",
|
||||
"sender@example.com",
|
||||
"to@example.com",
|
||||
"",
|
||||
)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil ICS bytes")
|
||||
}
|
||||
if !strings.Contains(string(got), "to@example.com") {
|
||||
t.Error("attendee missing")
|
||||
}
|
||||
}
|
||||
|
||||
180
shortcuts/mail/ics/builder.go
Normal file
180
shortcuts/mail/ics/builder.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package ics provides RFC 5545 iCalendar generation and parsing for mail calendar invitations.
|
||||
package ics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Event holds the data needed to generate an ICS VCALENDAR invitation.
|
||||
type Event struct {
|
||||
UID string // auto-generated if empty
|
||||
Summary string // SUMMARY (required)
|
||||
Location string // LOCATION (optional)
|
||||
Start time.Time // DTSTART (required)
|
||||
End time.Time // DTEND (required)
|
||||
Organizer Address // ORGANIZER
|
||||
Attendees []Address // ATTENDEE list (To + Cc, excluding Bcc)
|
||||
}
|
||||
|
||||
// Address represents a name + email pair for ORGANIZER / ATTENDEE.
|
||||
type Address struct {
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
// Build generates a RFC 5545 VCALENDAR byte slice with METHOD:REQUEST.
|
||||
// The output is suitable for use as a text/calendar MIME part.
|
||||
func Build(event Event) []byte {
|
||||
uid := event.UID
|
||||
if uid == "" {
|
||||
uid = uuid.New().String()
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
nowICS := formatICSTime(now)
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
b.WriteString("CALSCALE:GREGORIAN\r\n")
|
||||
b.WriteString("VERSION:2.0\r\n")
|
||||
b.WriteString("PRODID:-//Lark CLI//EN\r\n")
|
||||
b.WriteString("METHOD:REQUEST\r\n")
|
||||
b.WriteString("X-LARK-MAIL-DRAFT:TRUE\r\n")
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
writeFolded(&b, "UID", uid)
|
||||
writeFolded(&b, "DTSTAMP", nowICS)
|
||||
writeFolded(&b, "CREATED", nowICS)
|
||||
writeFolded(&b, "LAST-MODIFIED", nowICS)
|
||||
writeFolded(&b, "DTSTART", formatICSTime(event.Start.UTC()))
|
||||
writeFolded(&b, "DTEND", formatICSTime(event.End.UTC()))
|
||||
writeFolded(&b, "SUMMARY", escapeTextValue(event.Summary))
|
||||
if event.Location != "" {
|
||||
writeFolded(&b, "LOCATION", escapeTextValue(event.Location))
|
||||
}
|
||||
b.WriteString("STATUS:CONFIRMED\r\n")
|
||||
b.WriteString("TRANSP:OPAQUE\r\n")
|
||||
b.WriteString("SEQUENCE:0\r\n")
|
||||
if event.Organizer.Email != "" {
|
||||
organizer := "ORGANIZER;ROLE=CHAIR"
|
||||
if event.Organizer.Name != "" {
|
||||
organizer += ";CN=" + quoteCNParam(event.Organizer.Name)
|
||||
} else {
|
||||
organizer += ";CN=" + quoteCNParam(event.Organizer.Email)
|
||||
}
|
||||
writeFolded(&b, organizer, mailtoScheme+sanitizeMailtoAddress(event.Organizer.Email))
|
||||
}
|
||||
for _, a := range event.Attendees {
|
||||
attendee := "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL"
|
||||
if a.Name != "" {
|
||||
attendee += ";CN=" + quoteCNParam(a.Name)
|
||||
} else {
|
||||
attendee += ";CN=" + quoteCNParam(a.Email)
|
||||
}
|
||||
attendee += ";PARTSTAT=NEEDS-ACTION"
|
||||
writeFolded(&b, attendee, mailtoScheme+sanitizeMailtoAddress(a.Email))
|
||||
}
|
||||
b.WriteString("END:VEVENT\r\n")
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// formatICSTime formats a time.Time as ICS UTC: YYYYMMDDTHHMMSSZ.
|
||||
func formatICSTime(t time.Time) string {
|
||||
return t.Format("20060102T150405Z")
|
||||
}
|
||||
|
||||
// escapeTextValue escapes a string for use as an ICS TEXT value per RFC 5545
|
||||
// §3.3.11: backslash, newline, semicolon, and comma carry structural meaning
|
||||
// and must be escaped. Applied to SUMMARY, LOCATION, DESCRIPTION etc. — not
|
||||
// to identifiers (UID), date-times (DTSTART/DTEND), or URIs.
|
||||
//
|
||||
// Without this, a user-supplied summary containing a newline or colon would
|
||||
// let the payload inject a fake property line, e.g.
|
||||
//
|
||||
// --event-summary "foo\nDTSTART:20000101T000000Z"
|
||||
//
|
||||
// would turn into a second DTSTART line after folding.
|
||||
func escapeTextValue(s string) string {
|
||||
// Normalise CR / CRLF so downstream only sees LF.
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
// Order matters: escape backslash first so its own replacement is not
|
||||
// picked up by later rules.
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, ";", `\;`)
|
||||
s = strings.ReplaceAll(s, ",", `\,`)
|
||||
return s
|
||||
}
|
||||
|
||||
// quoteCNParam wraps a CN parameter value in double-quotes per RFC 5545 §3.2
|
||||
// when the value contains characters that are not allowed in an unquoted
|
||||
// paramtext (, ; :). Characters that are illegal inside a quoted-string are
|
||||
// stripped: DQUOTE (%x22) is excluded by QSAFE-CHAR, and control characters
|
||||
// (%x00–%x08, %x0A–%x1F, %x7F) would break the property line structure.
|
||||
func quoteCNParam(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '"' || r < 0x09 || (r >= 0x0A && r <= 0x1F) || r == 0x7F {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
if strings.ContainsAny(s, ",:;") {
|
||||
return `"` + s + `"`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writeFolded writes a property line with RFC 5545 line folding (75-octet limit).
|
||||
// Long lines are folded by inserting CRLF + space at UTF-8 character boundaries.
|
||||
// Continuation lines begin with a single SPACE (1 octet), so their content is
|
||||
// limited to 74 octets to keep the total physical line at ≤ 75 octets.
|
||||
func writeFolded(b *strings.Builder, name, value string) {
|
||||
line := fmt.Sprintf("%s:%s", name, value)
|
||||
const maxLineOctets = 75 // RFC 5545 §3.1: lines SHOULD NOT be longer than 75 octets
|
||||
limit := maxLineOctets
|
||||
for len(line) > limit {
|
||||
// Find the last complete UTF-8 character that fits within the limit.
|
||||
cut := 0
|
||||
for i := 0; i < len(line); {
|
||||
_, size := utf8.DecodeRuneInString(line[i:])
|
||||
if i+size > limit {
|
||||
break
|
||||
}
|
||||
i += size
|
||||
cut = i
|
||||
}
|
||||
if cut == 0 {
|
||||
// Single character exceeds limit (shouldn't happen in practice).
|
||||
cut = limit
|
||||
}
|
||||
b.WriteString(line[:cut])
|
||||
b.WriteString("\r\n ")
|
||||
line = line[cut:]
|
||||
limit = maxLineOctets - 1 // continuation lines: 1-octet SPACE + 74 content = 75
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// sanitizeMailtoAddress strips control characters (CR, LF, and other chars
|
||||
// below 0x20 or equal to 0x7F) from an email address before embedding it in a
|
||||
// MAILTO: URI value. Prevents property-injection attacks analogous to the CN
|
||||
// parameter protection in quoteCNParam.
|
||||
func sanitizeMailtoAddress(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
719
shortcuts/mail/ics/ics_test.go
Normal file
719
shortcuts/mail/ics/ics_test.go
Normal file
@@ -0,0 +1,719 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuild_Basic(t *testing.T) {
|
||||
event := Event{
|
||||
UID: "test-uid-123",
|
||||
Summary: "Product Review",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
|
||||
Attendees: []Address{
|
||||
{Name: "Alice", Email: "alice@example.com"},
|
||||
{Name: "Bob", Email: "bob@example.com"},
|
||||
},
|
||||
}
|
||||
// Unfold before assertion so long property lines (which exceed 75 octets and
|
||||
// are folded per RFC 5545) can be matched as a single contiguous string.
|
||||
ics := unfoldLines(string(Build(event)))
|
||||
|
||||
checks := []string{
|
||||
"BEGIN:VCALENDAR",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"VERSION:2.0",
|
||||
"METHOD:REQUEST",
|
||||
"X-LARK-MAIL-DRAFT:TRUE",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:test-uid-123",
|
||||
"DTSTAMP:",
|
||||
"CREATED:",
|
||||
"LAST-MODIFIED:",
|
||||
"DTSTART:20260420T060000Z",
|
||||
"DTEND:20260420T070000Z",
|
||||
"SUMMARY:Product Review",
|
||||
"STATUS:CONFIRMED",
|
||||
"TRANSP:OPAQUE",
|
||||
"SEQUENCE:0",
|
||||
"ORGANIZER;ROLE=CHAIR;CN=Sender:MAILTO:sender@example.com",
|
||||
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Alice;PARTSTAT=NEEDS-ACTION:MAILTO:alice@example.com",
|
||||
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Bob;PARTSTAT=NEEDS-ACTION:MAILTO:bob@example.com",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(ics, want) {
|
||||
t.Errorf("missing %q in ICS:\n%s", want, ics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_OrganizerFallsBackToEmailWhenNoName(t *testing.T) {
|
||||
event := Event{
|
||||
Summary: "Meeting",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
Organizer: Address{Email: "o@e.com"},
|
||||
Attendees: []Address{{Email: "a@e.com"}},
|
||||
}
|
||||
ics := unfoldLines(string(Build(event)))
|
||||
if !strings.Contains(ics, "ORGANIZER;ROLE=CHAIR;CN=o@e.com:MAILTO:o@e.com") {
|
||||
t.Errorf("ORGANIZER without name should fall back to email as CN:\n%s", ics)
|
||||
}
|
||||
if !strings.Contains(ics, "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=a@e.com;PARTSTAT=NEEDS-ACTION:MAILTO:a@e.com") {
|
||||
t.Errorf("ATTENDEE without name should fall back to email as CN:\n%s", ics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_WithLocation(t *testing.T) {
|
||||
event := Event{
|
||||
Summary: "Meeting",
|
||||
Location: "5F Conference Room",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
}
|
||||
ics := string(Build(event))
|
||||
if !strings.Contains(ics, "LOCATION:5F Conference Room") {
|
||||
t.Errorf("missing LOCATION in ICS:\n%s", ics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_NoLocation(t *testing.T) {
|
||||
event := Event{
|
||||
Summary: "Meeting",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
}
|
||||
ics := string(Build(event))
|
||||
if strings.Contains(ics, "LOCATION") {
|
||||
t.Errorf("should not have LOCATION when empty:\n%s", ics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_AutoUIDIsPureUUID(t *testing.T) {
|
||||
event := Event{
|
||||
Summary: "Test",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
}
|
||||
ics := string(Build(event))
|
||||
if !strings.Contains(ics, "UID:") {
|
||||
t.Fatal("missing UID")
|
||||
}
|
||||
// Extract the UID line to assert on its format.
|
||||
var uid string
|
||||
for _, line := range strings.Split(ics, "\r\n") {
|
||||
if strings.HasPrefix(line, "UID:") {
|
||||
uid = strings.TrimPrefix(line, "UID:")
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.Contains(uid, "@") {
|
||||
t.Errorf("auto-generated UID should be pure UUID (no @host suffix), got %q", uid)
|
||||
}
|
||||
// UUID v4 has 36 chars (8-4-4-4-12 plus 4 dashes).
|
||||
if len(uid) != 36 {
|
||||
t.Errorf("auto-generated UID should be 36-char UUID, got %d chars: %q", len(uid), uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_EscapesTextValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"semicolon", "a;b", `a\;b`},
|
||||
{"comma", "a,b", `a\,b`},
|
||||
{"backslash", `a\b`, `a\\b`},
|
||||
{"newline", "a\nb", `a\nb`},
|
||||
{"crlf", "a\r\nb", `a\nb`},
|
||||
{"mixed", `a;\,b` + "\n", `a\;\\\,b\n`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := escapeTextValue(tc.input); got != tc.want {
|
||||
t.Errorf("escapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_RejectsInjectionViaSummary proves that a malicious SUMMARY
|
||||
// containing a newline plus a fake property line cannot inject a second
|
||||
// DTSTART into the rendered ICS — the newline is escaped into a literal
|
||||
// "\n" sequence inside the SUMMARY value.
|
||||
func TestBuild_RejectsInjectionViaSummary(t *testing.T) {
|
||||
event := Event{
|
||||
Summary: "harmless\nDTSTART:19700101T000000Z",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
}
|
||||
ics := unfoldLines(string(Build(event)))
|
||||
|
||||
// Count occurrences of DTSTART at the start of a line (i.e., as an
|
||||
// actual property), ignoring the literal "DTSTART:" substring that
|
||||
// now appears inside the escaped SUMMARY value.
|
||||
dtstartPropertyLines := 0
|
||||
for _, line := range strings.Split(ics, "\r\n") {
|
||||
if strings.HasPrefix(line, "DTSTART:") {
|
||||
dtstartPropertyLines++
|
||||
}
|
||||
}
|
||||
if dtstartPropertyLines != 1 {
|
||||
t.Errorf("expected exactly one DTSTART: property line, got %d in:\n%s", dtstartPropertyLines, ics)
|
||||
}
|
||||
if !strings.Contains(ics, `SUMMARY:harmless\nDTSTART:19700101T000000Z`) {
|
||||
t.Errorf("expected escaped SUMMARY to contain literal \\n, got:\n%s", ics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_CNWithSpecialCharsIsQuoted(t *testing.T) {
|
||||
event := Event{
|
||||
Summary: "Meeting",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
Organizer: Address{Name: "Smith, Alice", Email: "alice@example.com"},
|
||||
Attendees: []Address{
|
||||
{Name: "Doe; Bob", Email: "bob@example.com"},
|
||||
{Name: "Plain Name", Email: "plain@example.com"},
|
||||
},
|
||||
}
|
||||
ics := unfoldLines(string(Build(event)))
|
||||
if !strings.Contains(ics, `CN="Smith, Alice"`) {
|
||||
t.Errorf("expected quoted CN for organizer name with comma:\n%s", ics)
|
||||
}
|
||||
if !strings.Contains(ics, `CN="Doe; Bob"`) {
|
||||
t.Errorf("expected quoted CN for attendee name with semicolon:\n%s", ics)
|
||||
}
|
||||
// Names without special chars must NOT be double-quoted.
|
||||
if !strings.Contains(ics, "CN=Plain Name") || strings.Contains(ics, `CN="Plain Name"`) {
|
||||
t.Errorf("plain name should be unquoted:\n%s", ics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_EmailAddressSanitized(t *testing.T) {
|
||||
// CR/LF inside an email address must not produce injected property lines.
|
||||
event := Event{
|
||||
Summary: "Meeting",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
Organizer: Address{Name: "Alice", Email: "alice@example.com\r\nX-INJECTED:bad"},
|
||||
Attendees: []Address{{Name: "Bob", Email: "bob@example.com\nY-INJECTED:bad"}},
|
||||
}
|
||||
output := string(Build(event))
|
||||
if strings.Contains(output, "\r\nX-INJECTED") {
|
||||
t.Error("organizer email CR/LF injection not sanitized")
|
||||
}
|
||||
if strings.Contains(output, "\r\nY-INJECTED") {
|
||||
t.Error("attendee email CR/LF injection not sanitized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_CNStripsControlChars(t *testing.T) {
|
||||
// A display name containing CR, LF, or other control characters must not
|
||||
// produce extra ICS property lines (injection via CN parameter).
|
||||
event := Event{
|
||||
Summary: "Meeting",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
Organizer: Address{Name: "Alice\r\nDTSTART:99999999", Email: "alice@example.com"},
|
||||
Attendees: []Address{
|
||||
{Name: "Bob\nX-INJECTED:bad", Email: "bob@example.com"},
|
||||
},
|
||||
}
|
||||
output := string(Build(event))
|
||||
// Check that control chars don't produce injected property lines.
|
||||
// A standalone ICS property line starts at the beginning of a CRLF-delimited line.
|
||||
if strings.Contains(output, "\r\nDTSTART:99999999") {
|
||||
t.Error("ICS output contains injected DTSTART property line via organizer CN")
|
||||
}
|
||||
if strings.Contains(output, "\r\nX-INJECTED") {
|
||||
t.Error("ICS output contains injected X-INJECTED property line via attendee CN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_LineFolding(t *testing.T) {
|
||||
event := Event{
|
||||
Summary: strings.Repeat("A", 100), // long summary triggers folding
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
}
|
||||
ics := string(Build(event))
|
||||
// Every physical line (first line and continuation lines alike) must be
|
||||
// ≤ 75 octets excluding the CRLF terminator per RFC 5545 §3.1.
|
||||
for _, line := range strings.Split(ics, "\r\n") {
|
||||
if len(line) > 75 {
|
||||
t.Errorf("line exceeds 75 octets: %q (len=%d)", line, len(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_Basic(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"METHOD:REQUEST\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:abc123@larksuite.com\r\n" +
|
||||
"DTSTART:20260420T060000Z\r\n" +
|
||||
"DTEND:20260420T070000Z\r\n" +
|
||||
"SUMMARY:Product Review\r\n" +
|
||||
"LOCATION:5F Room\r\n" +
|
||||
"ORGANIZER;CN=Sender:mailto:sender@example.com\r\n" +
|
||||
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Alice:mailto:alice@example.com\r\n" +
|
||||
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Bob:mailto:bob@example.com\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if event.Method != "REQUEST" {
|
||||
t.Errorf("Method = %q, want REQUEST", event.Method)
|
||||
}
|
||||
if event.IsLarkDraft {
|
||||
t.Error("IsLarkDraft = true, want false (no X-LARK-MAIL-DRAFT in input)")
|
||||
}
|
||||
if event.UID != "abc123@larksuite.com" {
|
||||
t.Errorf("UID = %q, want abc123@larksuite.com", event.UID)
|
||||
}
|
||||
if event.Summary != "Product Review" {
|
||||
t.Errorf("Summary = %q, want Product Review", event.Summary)
|
||||
}
|
||||
if event.Location != "5F Room" {
|
||||
t.Errorf("Location = %q, want 5F Room", event.Location)
|
||||
}
|
||||
if event.Organizer != "sender@example.com" {
|
||||
t.Errorf("Organizer = %q, want sender@example.com", event.Organizer)
|
||||
}
|
||||
if len(event.Attendees) != 2 {
|
||||
t.Fatalf("Attendees count = %d, want 2", len(event.Attendees))
|
||||
}
|
||||
if event.Attendees[0] != "alice@example.com" {
|
||||
t.Errorf("Attendees[0] = %q, want alice@example.com", event.Attendees[0])
|
||||
}
|
||||
if event.Attendees[1] != "bob@example.com" {
|
||||
t.Errorf("Attendees[1] = %q, want bob@example.com", event.Attendees[1])
|
||||
}
|
||||
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
||||
if !event.Start.Equal(wantStart) {
|
||||
t.Errorf("Start = %v, want %v", event.Start, wantStart)
|
||||
}
|
||||
wantEnd := time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC)
|
||||
if !event.End.Equal(wantEnd) {
|
||||
t.Errorf("End = %v, want %v", event.End, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_IsLarkDraft(t *testing.T) {
|
||||
icsWithMarker := "BEGIN:VCALENDAR\r\n" +
|
||||
"METHOD:REQUEST\r\n" +
|
||||
"X-LARK-MAIL-DRAFT:TRUE\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:draft-test\r\n" +
|
||||
"DTSTART:20260420T060000Z\r\n" +
|
||||
"DTEND:20260420T070000Z\r\n" +
|
||||
"SUMMARY:Draft Event\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
event := ParseEvent(icsWithMarker)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if !event.IsLarkDraft {
|
||||
t.Error("IsLarkDraft = false, want true")
|
||||
}
|
||||
|
||||
icsWithoutMarker := "BEGIN:VCALENDAR\r\n" +
|
||||
"METHOD:REQUEST\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:external-test\r\n" +
|
||||
"DTSTART:20260420T060000Z\r\n" +
|
||||
"DTEND:20260420T070000Z\r\n" +
|
||||
"SUMMARY:External Event\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
event2 := ParseEvent(icsWithoutMarker)
|
||||
if event2 == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if event2.IsLarkDraft {
|
||||
t.Error("IsLarkDraft = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_WithTZID(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:tz-test\r\n" +
|
||||
"DTSTART;TZID=Asia/Shanghai:20260420T140000\r\n" +
|
||||
"DTEND;TZID=Asia/Shanghai:20260420T150000\r\n" +
|
||||
"SUMMARY:TZ Test\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
// 14:00 Asia/Shanghai = 06:00 UTC
|
||||
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
||||
if !event.Start.Equal(wantStart) {
|
||||
t.Errorf("Start = %v, want %v", event.Start, wantStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_FoldedLines(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:fold-test\r\n" +
|
||||
"DTSTART:20260420T060000Z\r\n" +
|
||||
"DTEND:20260420T070000Z\r\n" +
|
||||
"SUMMARY:This is a very long summary that should be unfolded correctly by th\r\n" +
|
||||
" e parser when processing\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
want := "This is a very long summary that should be unfolded correctly by the parser when processing"
|
||||
if event.Summary != want {
|
||||
t.Errorf("Summary = %q, want %q", event.Summary, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_FoldedLines_LFOnly(t *testing.T) {
|
||||
// Some mail servers strip \r before storage, producing LF-only ICS.
|
||||
ics := "BEGIN:VCALENDAR\n" +
|
||||
"BEGIN:VEVENT\n" +
|
||||
"UID:lf-fold-test\n" +
|
||||
"DTSTART:20260420T060000Z\n" +
|
||||
"DTEND:20260420T070000Z\n" +
|
||||
"SUMMARY:This is a very long summary that should be unfolded correctly by th\n" +
|
||||
" e parser when LF-only folding is used\n" +
|
||||
"END:VEVENT\n" +
|
||||
"END:VCALENDAR\n"
|
||||
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil for LF-only ICS")
|
||||
}
|
||||
want := "This is a very long summary that should be unfolded correctly by the parser when LF-only folding is used"
|
||||
if event.Summary != want {
|
||||
t.Errorf("Summary = %q, want %q", event.Summary, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_NoVEvent(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
|
||||
event := ParseEvent(ics)
|
||||
if event != nil {
|
||||
t.Error("expected nil for ICS without VEVENT")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseEvent_OrganizerWithoutMailto covers the case where the backend
|
||||
// re-serializes our ICS and drops the "MAILTO:" scheme prefix. Observed in
|
||||
// practice on drafts returned by user_mailboxes/me/drafts.get.
|
||||
func TestParseEvent_OrganizerWithoutMailto(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:no-mailto-test\r\n" +
|
||||
"DTSTART:20260420T060000Z\r\n" +
|
||||
"DTEND:20260420T070000Z\r\n" +
|
||||
"SUMMARY:Test\r\n" +
|
||||
"ORGANIZER;CN=org@example.com:org@example.com\r\n" +
|
||||
"ATTENDEE;PARTSTAT=NEEDS-ACTION;CN=att@example.com:att@example.com\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if event.Organizer != "org@example.com" {
|
||||
t.Errorf("Organizer = %q, want org@example.com (parser must accept bare email when mailto: is absent)", event.Organizer)
|
||||
}
|
||||
if len(event.Attendees) != 1 || event.Attendees[0] != "att@example.com" {
|
||||
t.Errorf("Attendees = %v, want [att@example.com]", event.Attendees)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_MailtoCaseInsensitive(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:case-test\r\n" +
|
||||
"DTSTART:20260420T060000Z\r\n" +
|
||||
"DTEND:20260420T070000Z\r\n" +
|
||||
"SUMMARY:Test\r\n" +
|
||||
"ORGANIZER;CN=Sender:MAILTO:sender@example.com\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if event.Organizer != "sender@example.com" {
|
||||
t.Errorf("Organizer = %q, want sender@example.com (uppercase MAILTO: should be accepted)", event.Organizer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_RecurrenceIDPopulatesOriginalTime(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:recurring-exception\r\n" +
|
||||
"DTSTART:20260501T020000Z\r\n" +
|
||||
"DTEND:20260501T030000Z\r\n" +
|
||||
"RECURRENCE-ID:20260501T020000Z\r\n" +
|
||||
"SUMMARY:Exception instance\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
// 2026-05-01 02:00:00 UTC = 1777600800
|
||||
if event.OriginalTime != 1777600800 {
|
||||
t.Errorf("OriginalTime = %d, want 1777600800", event.OriginalTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_NoRecurrenceIDYieldsZero(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:single-event\r\n" +
|
||||
"DTSTART:20260420T060000Z\r\n" +
|
||||
"DTEND:20260420T070000Z\r\n" +
|
||||
"SUMMARY:Single\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
event := ParseEvent(ics)
|
||||
if event == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if event.OriginalTime != 0 {
|
||||
t.Errorf("OriginalTime = %d, want 0 for non-recurring event", event.OriginalTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnescapeTextValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"plain", "hello", "hello"},
|
||||
{"semicolon", `a\;b`, "a;b"},
|
||||
{"comma", `a\,b`, "a,b"},
|
||||
{"backslash", `a\\b`, `a\b`},
|
||||
{"newline_lower", `a\nb`, "a\nb"},
|
||||
{"newline_upper", `a\Nb`, "a\nb"},
|
||||
{"mixed", `a\;\\\,b\n`, "a;\\,b\n"},
|
||||
{"dangling_backslash_kept", `ends\`, `ends\`},
|
||||
{"unknown_escape_kept", `\x`, `\x`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := unescapeTextValue(tc.input); got != tc.want {
|
||||
t.Errorf("unescapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_SpecialCharsInSummaryAndLocation(t *testing.T) {
|
||||
event := Event{
|
||||
UID: "rt-special",
|
||||
Summary: `Review;with,special\chars` + "\n" + `and newline`,
|
||||
Location: `B1,Room 3;floor 2`,
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
}
|
||||
parsed := ParseEvent(string(Build(event)))
|
||||
if parsed == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if !parsed.IsLarkDraft {
|
||||
t.Error("IsLarkDraft = false after roundtrip, want true (Build should write X-LARK-MAIL-DRAFT)")
|
||||
}
|
||||
if parsed.Summary != event.Summary {
|
||||
t.Errorf("Summary roundtrip: got %q, want %q", parsed.Summary, event.Summary)
|
||||
}
|
||||
if parsed.Location != event.Location {
|
||||
t.Errorf("Location roundtrip: got %q, want %q", parsed.Location, event.Location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_WriteFolded_SingleCharExceeds75Bytes(t *testing.T) {
|
||||
// A single multibyte rune that is > 75 bytes is not reachable in practice,
|
||||
// but we exercise the cut==0 fallback by constructing a fake line via a
|
||||
// 75-octet name followed by a multi-octet rune that crosses the boundary.
|
||||
// The simplest way: a name of exactly 74 chars + ':' = 75, then a multi-byte
|
||||
// rune — the first iteration has cut==0, triggering the fallback.
|
||||
var b strings.Builder
|
||||
longName := strings.Repeat("A", 74)
|
||||
// value starts with a 3-byte UTF-8 rune (€ = 0xE2 0x82 0xAC)
|
||||
writeFolded(&b, longName, "€remainder")
|
||||
result := b.String()
|
||||
if !strings.Contains(result, "\r\n ") {
|
||||
t.Errorf("expected line folding CRLF+SP in output:\n%q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitProperty_NoColon(t *testing.T) {
|
||||
name, value := splitProperty("NOCOLON")
|
||||
if name != "NOCOLON" || value != "" {
|
||||
t.Errorf("splitProperty(no colon): got name=%q value=%q, want NOCOLON/\"\"", name, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitProperty_QuotedColon(t *testing.T) {
|
||||
// A colon inside a quoted CN param must not be treated as the separator.
|
||||
name, value := splitProperty(`ORGANIZER;CN="Doe: Jane":mailto:alice@example.com`)
|
||||
if name != `ORGANIZER;CN="Doe: Jane"` {
|
||||
t.Errorf("name = %q, want ORGANIZER;CN=\"Doe: Jane\"", name)
|
||||
}
|
||||
if value != "mailto:alice@example.com" {
|
||||
t.Errorf("value = %q, want mailto:alice@example.com", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSTime_TZIDCaseInsensitive(t *testing.T) {
|
||||
// TZID parameter name is case-insensitive per RFC 5545 §3.2.
|
||||
result := parseICSTime("20260420T140000", "DTSTART;tzid=Asia/Shanghai")
|
||||
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
||||
if !result.Equal(want) {
|
||||
t.Errorf("parseICSTime with lowercase tzid= = %v, want %v", result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSTime_TZIDWithTrailingParam(t *testing.T) {
|
||||
// Trailing parameters after TZID (e.g. ;VALUE=DATE-TIME) must not be
|
||||
// included in the timezone name passed to time.LoadLocation.
|
||||
result := parseICSTime("20260420T140000", "DTSTART;TZID=Asia/Shanghai;VALUE=DATE-TIME")
|
||||
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
||||
if !result.Equal(want) {
|
||||
t.Errorf("parseICSTime with trailing ;VALUE= = %v, want %v", result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSTime_DateOnly(t *testing.T) {
|
||||
// All-day event: YYYYMMDD format
|
||||
result := parseICSTime("20260420", "DTSTART;VALUE=DATE")
|
||||
want := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
|
||||
if !result.Equal(want) {
|
||||
t.Errorf("parseICSTime date-only = %v, want %v", result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSTime_LocalWithoutTZ(t *testing.T) {
|
||||
// Local time without timezone suffix (no Z, no TZID) — treated as UTC
|
||||
result := parseICSTime("20260420T140000", "DTSTART")
|
||||
want := time.Date(2026, 4, 20, 14, 0, 0, 0, time.UTC)
|
||||
if !result.Equal(want) {
|
||||
t.Errorf("parseICSTime local = %v, want %v", result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICSTime_InvalidReturnsZero(t *testing.T) {
|
||||
result := parseICSTime("not-a-date", "DTSTART")
|
||||
if !result.IsZero() {
|
||||
t.Errorf("parseICSTime invalid = %v, want zero", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMailto_NoAt(t *testing.T) {
|
||||
result := extractMailto("notanemail")
|
||||
if result != "" {
|
||||
t.Errorf("extractMailto(no @) = %q, want empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
original := Event{
|
||||
UID: "roundtrip-test",
|
||||
Summary: "Roundtrip Meeting",
|
||||
Location: "Room 301",
|
||||
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
||||
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
|
||||
Attendees: []Address{
|
||||
{Name: "Alice", Email: "alice@example.com"},
|
||||
},
|
||||
}
|
||||
icsBytes := Build(original)
|
||||
parsed := ParseEvent(string(icsBytes))
|
||||
if parsed == nil {
|
||||
t.Fatal("ParseEvent returned nil on Build output")
|
||||
}
|
||||
if parsed.UID != original.UID {
|
||||
t.Errorf("UID roundtrip: %q != %q", parsed.UID, original.UID)
|
||||
}
|
||||
if parsed.Summary != original.Summary {
|
||||
t.Errorf("Summary roundtrip: %q != %q", parsed.Summary, original.Summary)
|
||||
}
|
||||
if parsed.Location != original.Location {
|
||||
t.Errorf("Location roundtrip: %q != %q", parsed.Location, original.Location)
|
||||
}
|
||||
if !parsed.Start.Equal(original.Start) {
|
||||
t.Errorf("Start roundtrip: %v != %v", parsed.Start, original.Start)
|
||||
}
|
||||
if parsed.Organizer != original.Organizer.Email {
|
||||
t.Errorf("Organizer roundtrip: %q != %q", parsed.Organizer, original.Organizer.Email)
|
||||
}
|
||||
if len(parsed.Attendees) != 1 || parsed.Attendees[0] != "alice@example.com" {
|
||||
t.Errorf("Attendees roundtrip: %v", parsed.Attendees)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_LowercaseAndParameterizedProps(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
|
||||
"uid:lowercased-uid-value\r\n" +
|
||||
"SUMMARY;LANGUAGE=en-US:Team Sync\r\n" +
|
||||
"location;ALTREP=\"cid:part1\":Room 301\r\n" +
|
||||
"DTSTART:20260501T100000Z\r\n" +
|
||||
"DTEND:20260501T110000Z\r\n" +
|
||||
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
ev := ParseEvent(ics)
|
||||
if ev == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
if ev.UID != "lowercased-uid-value" {
|
||||
t.Errorf("UID: got %q", ev.UID)
|
||||
}
|
||||
if ev.Summary != "Team Sync" {
|
||||
t.Errorf("Summary: got %q", ev.Summary)
|
||||
}
|
||||
if ev.Location != "Room 301" {
|
||||
t.Errorf("Location: got %q", ev.Location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEvent_StartEndUTCInOutput(t *testing.T) {
|
||||
// Verify that times with TZID are parsed with correct offset
|
||||
// (UTC normalization in output is done by the helpers layer; parser
|
||||
// returns time.Time which callers can call .UTC() on).
|
||||
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
|
||||
"DTSTART;TZID=Asia/Shanghai:20260501T180000\r\n" +
|
||||
"DTEND;TZID=Asia/Shanghai:20260501T190000\r\n" +
|
||||
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
ev := ParseEvent(ics)
|
||||
if ev == nil {
|
||||
t.Fatal("ParseEvent returned nil")
|
||||
}
|
||||
wantStart := "2026-05-01T10:00:00Z"
|
||||
if got := ev.Start.UTC().Format(time.RFC3339); got != wantStart {
|
||||
t.Errorf("Start UTC: got %q, want %q", got, wantStart)
|
||||
}
|
||||
}
|
||||
222
shortcuts/mail/ics/parser.go
Normal file
222
shortcuts/mail/ics/parser.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mailtoScheme is the canonical case for the RFC 5545 ORGANIZER / ATTENDEE
|
||||
// CAL-ADDRESS URI scheme. Emitted by the builder in upper-case to match
|
||||
// Feishu client output; matched case-insensitively by the parser.
|
||||
const mailtoScheme = "MAILTO:"
|
||||
|
||||
// ParsedEvent holds key fields extracted from an ICS VCALENDAR.
|
||||
type ParsedEvent struct {
|
||||
Method string // VCALENDAR-level METHOD (REQUEST/REPLY/CANCEL)
|
||||
IsLarkDraft bool // true when VCALENDAR contains X-LARK-MAIL-DRAFT (Feishu private property indicating the event is editable)
|
||||
UID string // VEVENT UID
|
||||
Summary string // VEVENT SUMMARY, RFC 5545 TEXT unescaped
|
||||
Location string // VEVENT LOCATION, RFC 5545 TEXT unescaped
|
||||
Start time.Time // VEVENT DTSTART
|
||||
End time.Time // VEVENT DTEND
|
||||
Organizer string // ORGANIZER email (from MAILTO: URI or bare email)
|
||||
Attendees []string // ATTENDEE emails (from MAILTO: URIs or bare emails)
|
||||
OriginalTime int64 // RECURRENCE-ID as Unix seconds, 0 if not present. Used together with UID to derive the Feishu calendar event_id = UID + "_" + OriginalTime.
|
||||
}
|
||||
|
||||
// ParseEvent extracts key fields from an ICS VCALENDAR string.
|
||||
// Returns nil if no VEVENT is found.
|
||||
func ParseEvent(icsText string) *ParsedEvent {
|
||||
// Step 1: line unfolding (RFC 5545 §3.1)
|
||||
unfolded := unfoldLines(icsText)
|
||||
|
||||
lines := strings.Split(unfolded, "\n")
|
||||
var event ParsedEvent
|
||||
inVEvent := false
|
||||
foundVEvent := false
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
// VCALENDAR-level properties
|
||||
if !inVEvent && strings.HasPrefix(upper, "METHOD:") {
|
||||
event.Method = strings.TrimSpace(line[len("METHOD:"):])
|
||||
continue
|
||||
}
|
||||
if !inVEvent && strings.HasPrefix(upper, "X-LARK-MAIL-DRAFT:") {
|
||||
event.IsLarkDraft = true
|
||||
continue
|
||||
}
|
||||
|
||||
if upper == "BEGIN:VEVENT" {
|
||||
inVEvent = true
|
||||
continue
|
||||
}
|
||||
if upper == "END:VEVENT" {
|
||||
inVEvent = false
|
||||
foundVEvent = true
|
||||
continue
|
||||
}
|
||||
|
||||
if !inVEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
// VEVENT properties — RFC 5545 §3.1: property names are
|
||||
// case-insensitive and may carry parameters (NAME;PARAM=v:value).
|
||||
name, value := splitProperty(line)
|
||||
propUpper := strings.ToUpper(name)
|
||||
switch {
|
||||
case propUpper == "UID" || strings.HasPrefix(propUpper, "UID;"):
|
||||
event.UID = value
|
||||
case propUpper == "SUMMARY" || strings.HasPrefix(propUpper, "SUMMARY;"):
|
||||
event.Summary = unescapeTextValue(value)
|
||||
case propUpper == "LOCATION" || strings.HasPrefix(propUpper, "LOCATION;"):
|
||||
event.Location = unescapeTextValue(value)
|
||||
case propUpper == "DTSTART" || strings.HasPrefix(propUpper, "DTSTART;"):
|
||||
event.Start = parseICSTime(value, name)
|
||||
case propUpper == "DTEND" || strings.HasPrefix(propUpper, "DTEND;"):
|
||||
event.End = parseICSTime(value, name)
|
||||
case propUpper == "RECURRENCE-ID" || strings.HasPrefix(propUpper, "RECURRENCE-ID;"):
|
||||
if t := parseICSTime(value, name); !t.IsZero() {
|
||||
event.OriginalTime = t.Unix()
|
||||
}
|
||||
case propUpper == "ORGANIZER" || strings.HasPrefix(propUpper, "ORGANIZER;"):
|
||||
if email := extractMailto(value); email != "" {
|
||||
event.Organizer = email
|
||||
}
|
||||
case propUpper == "ATTENDEE" || strings.HasPrefix(propUpper, "ATTENDEE;"):
|
||||
if email := extractMailto(value); email != "" {
|
||||
event.Attendees = append(event.Attendees, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundVEvent {
|
||||
return nil
|
||||
}
|
||||
return &event
|
||||
}
|
||||
|
||||
// unfoldLines reverses RFC 5545 line folding: CRLF (or bare LF) followed by
|
||||
// a single whitespace character is merged back into the preceding line.
|
||||
// CRLF forms are handled first so that "\r\n " is consumed as a unit and does
|
||||
// not leave a stray "\r" for the LF-only pass to mis-process.
|
||||
func unfoldLines(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n ", "")
|
||||
s = strings.ReplaceAll(s, "\r\n\t", "")
|
||||
// LF-only folding — produced by some mail servers that strip \r.
|
||||
s = strings.ReplaceAll(s, "\n ", "")
|
||||
s = strings.ReplaceAll(s, "\n\t", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// splitProperty splits "NAME;PARAMS:VALUE" into (name-with-params, value).
|
||||
// It scans for the first colon that is not inside a double-quoted parameter
|
||||
// value (e.g. CN="Doe: Jane"), per RFC 5545 §3.1.
|
||||
func splitProperty(line string) (string, string) {
|
||||
inQuote := false
|
||||
for i := 0; i < len(line); i++ {
|
||||
switch line[i] {
|
||||
case '"':
|
||||
inQuote = !inQuote
|
||||
case ':':
|
||||
if !inQuote {
|
||||
return line[:i], line[i+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return line, ""
|
||||
}
|
||||
|
||||
// parseICSTime parses ICS datetime formats:
|
||||
// - 20260420T060000Z (UTC)
|
||||
// - TZID=Asia/Shanghai:20260420T140000 (with timezone in property params)
|
||||
// - 20260420T140000 (local, treated as UTC)
|
||||
func parseICSTime(value, propName string) time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Check for TZID in property params: DTSTART;TZID=Asia/Shanghai
|
||||
// Case-insensitive search (RFC 5545 §3.2 param names are case-insensitive).
|
||||
// Stop at the next ';' so trailing params like ;VALUE=DATE-TIME are excluded.
|
||||
if idx := strings.Index(strings.ToUpper(propName), "TZID="); idx >= 0 {
|
||||
tzPart := propName[idx+5:] // skip past "TZID="
|
||||
if end := strings.IndexByte(tzPart, ';'); end >= 0 {
|
||||
tzPart = tzPart[:end]
|
||||
}
|
||||
if loc, err := time.LoadLocation(tzPart); err == nil {
|
||||
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UTC format: YYYYMMDDTHHMMSSZ
|
||||
if t, err := time.Parse("20060102T150405Z", value); err == nil {
|
||||
return t
|
||||
}
|
||||
|
||||
// Date-only: YYYYMMDD (all-day events)
|
||||
if t, err := time.Parse("20060102", value); err == nil {
|
||||
return t
|
||||
}
|
||||
|
||||
// Local time without timezone (treat as UTC)
|
||||
if t, err := time.Parse("20060102T150405", value); err == nil {
|
||||
return t
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// unescapeTextValue reverses escapeTextValue per RFC 5545 §3.3.11, turning
|
||||
// the ICS on-wire representation back into a plain Go string. Only applied
|
||||
// to TEXT-typed properties (SUMMARY, LOCATION, DESCRIPTION, etc.) —
|
||||
// identifiers, date-times, and URIs are parsed as-is.
|
||||
func unescapeTextValue(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
switch s[i+1] {
|
||||
case 'n', 'N':
|
||||
b.WriteByte('\n')
|
||||
i++
|
||||
continue
|
||||
case '\\', ';', ',':
|
||||
b.WriteByte(s[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// extractMailto extracts the email address from an ICS ORGANIZER/ATTENDEE value.
|
||||
// Accepts both "mailto:user@example.com" (RFC 5545 standard, case-insensitive per
|
||||
// RFC 3986 §3.1) and a bare "user@example.com" value (observed in backend-regenerated
|
||||
// ICS where the mailto: scheme prefix is dropped).
|
||||
func extractMailto(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
lower := strings.ToLower(value)
|
||||
if idx := strings.Index(lower, strings.ToLower(mailtoScheme)); idx >= 0 {
|
||||
return strings.TrimSpace(value[idx+len(mailtoScheme):])
|
||||
}
|
||||
if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t") {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
182
shortcuts/mail/mail_calendar_shortcuts_test.go
Normal file
182
shortcuts/mail/mail_calendar_shortcuts_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// calendarEventArgs are CLI flags that embed a calendar event in a compose command.
|
||||
var calendarEventArgs = []string{
|
||||
"--event-summary", "Team Sync",
|
||||
"--event-start", "2026-05-10T10:00+08:00",
|
||||
"--event-end", "2026-05-10T11:00+08:00",
|
||||
}
|
||||
|
||||
// extractEMLFromDraftsStub decodes the base64url EML from the captured request body.
|
||||
func extractEMLFromDraftsStub(t *testing.T, stub *httpmock.Stub) string {
|
||||
t.Helper()
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
raw, _ := reqBody["raw"].(string)
|
||||
decoded, err := base64.URLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url decode raw: %v", err)
|
||||
}
|
||||
return string(decoded)
|
||||
}
|
||||
|
||||
// assertCalendarInEML checks that the decoded EML contains a text/calendar part.
|
||||
func assertCalendarInEML(t *testing.T, eml string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(eml, "text/calendar") {
|
||||
t.Errorf("expected text/calendar part in EML:\n%s", eml)
|
||||
}
|
||||
if !strings.Contains(eml, "method=REQUEST") {
|
||||
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
// stubSourceMessage registers the minimum stubs to fetch a simple source message
|
||||
// (used by reply/forward/reply-all).
|
||||
func stubSourceMessage(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/user_mailboxes/me/messages/msg_001",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"message_id": "msg_001",
|
||||
"thread_id": "thread_001",
|
||||
"smtp_message_id": "<msg_001@example.com>",
|
||||
"subject": "Re: Original",
|
||||
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
|
||||
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
|
||||
"cc": []interface{}{},
|
||||
"bcc": []interface{}{},
|
||||
"body_html": base64.URLEncoding.EncodeToString([]byte("<p>Original</p>")),
|
||||
"body_plain_text": base64.URLEncoding.EncodeToString([]byte("Original")),
|
||||
"internal_date": "1704067200000",
|
||||
"attachments": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +reply with calendar event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReply_WithCalendarEvent(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubSourceMessage(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
|
||||
},
|
||||
})
|
||||
draftsStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "draft_001"},
|
||||
},
|
||||
}
|
||||
reg.Register(draftsStub)
|
||||
|
||||
args := append([]string{
|
||||
"+reply",
|
||||
"--message-id", "msg_001",
|
||||
"--body", "<p>Let us meet</p>",
|
||||
}, calendarEventArgs...)
|
||||
if err := runMountedMailShortcut(t, MailReply, args, f, stdout); err != nil {
|
||||
t.Fatalf("+reply with calendar failed: %v", err)
|
||||
}
|
||||
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +reply-all with calendar event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReplyAll_WithCalendarEvent(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubSourceMessage(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
|
||||
},
|
||||
})
|
||||
draftsStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "draft_001"},
|
||||
},
|
||||
}
|
||||
reg.Register(draftsStub)
|
||||
|
||||
args := append([]string{
|
||||
"+reply-all",
|
||||
"--message-id", "msg_001",
|
||||
"--body", "<p>Let us meet</p>",
|
||||
}, calendarEventArgs...)
|
||||
if err := runMountedMailShortcut(t, MailReplyAll, args, f, stdout); err != nil {
|
||||
t.Fatalf("+reply-all with calendar failed: %v", err)
|
||||
}
|
||||
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +forward with calendar event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestForward_WithCalendarEvent(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubSourceMessage(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
|
||||
},
|
||||
})
|
||||
draftsStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "draft_001"},
|
||||
},
|
||||
}
|
||||
reg.Register(draftsStub)
|
||||
|
||||
args := append([]string{
|
||||
"+forward",
|
||||
"--message-id", "msg_001",
|
||||
"--to", "carol@example.com",
|
||||
"--body", "<p>FYI</p>",
|
||||
}, calendarEventArgs...)
|
||||
if err := runMountedMailShortcut(t, MailForward, args, f, stdout); err != nil {
|
||||
t.Fatalf("+forward with calendar failed: %v", err)
|
||||
}
|
||||
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
|
||||
}
|
||||
@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
@@ -90,6 +91,9 @@ var MailDraftCreate = common.Shortcut{
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -300,6 +304,9 @@ func buildRawEMLForDraftCreate(
|
||||
return "", err
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil {
|
||||
bld = bld.CalendarBody(calData)
|
||||
}
|
||||
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
|
||||
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
|
||||
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes
|
||||
|
||||
@@ -5,6 +5,8 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,6 +16,30 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newRuntimeWithEventFlags creates a RuntimeContext with --from and calendar event flags.
|
||||
func newRuntimeWithEventFlags(from, summary, start, end, location string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"from", "mailbox", "event-summary", "event-start", "event-end", "event-location"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
if from != "" {
|
||||
_ = cmd.Flags().Set("from", from)
|
||||
}
|
||||
if summary != "" {
|
||||
_ = cmd.Flags().Set("event-summary", summary)
|
||||
}
|
||||
if start != "" {
|
||||
_ = cmd.Flags().Set("event-start", start)
|
||||
}
|
||||
if end != "" {
|
||||
_ = cmd.Flags().Set("event-end", end)
|
||||
}
|
||||
if location != "" {
|
||||
_ = cmd.Flags().Set("event-location", location)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newRuntimeWithFrom creates a minimal RuntimeContext with --from flag set.
|
||||
func newRuntimeWithFrom(from string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
@@ -269,6 +295,31 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
|
||||
rt := newRuntimeWithEventFlags("sender@example.com", "Team Sync", "2026-05-10T10:00+08:00", "2026-05-10T11:00+08:00", "Room 301")
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
To: "alice@example.com",
|
||||
Subject: "Team Sync",
|
||||
Body: "<p>Please join us</p>",
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(rawEML)
|
||||
if !strings.Contains(eml, "text/calendar") {
|
||||
t.Errorf("expected text/calendar part in EML:\n%s", eml)
|
||||
}
|
||||
if !strings.Contains(eml, "method=REQUEST") {
|
||||
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
|
||||
}
|
||||
if !strings.Contains(eml, "multipart/alternative") {
|
||||
t.Errorf("expected calendar inside multipart/alternative:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftCreatePrettyOutputsReference verifies mail draft create pretty outputs reference.
|
||||
func TestMailDraftCreatePrettyOutputsReference(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
@@ -316,3 +367,56 @@ func TestMailDraftCreatePrettyOutputsReference(t *testing.T) {
|
||||
t.Fatalf("expected reference in pretty output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailDraftCreate_WithCalendarEventFlags(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
draftsStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "draft_cal_001"},
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
|
||||
},
|
||||
})
|
||||
reg.Register(draftsStub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftCreate, []string{
|
||||
"+draft-create",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Team Sync",
|
||||
"--body", "<p>Please join us</p>",
|
||||
"--event-summary", "Team Sync",
|
||||
"--event-start", "2026-05-10T10:00+08:00",
|
||||
"--event-end", "2026-05-10T11:00+08:00",
|
||||
"--event-location", "Room 301",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("draft create with calendar failed: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal captured request body: %v", err)
|
||||
}
|
||||
raw, _ := reqBody["raw"].(string)
|
||||
decoded, decErr := base64.URLEncoding.DecodeString(raw)
|
||||
if decErr != nil {
|
||||
t.Fatalf("base64url decode raw: %v", decErr)
|
||||
}
|
||||
eml := string(decoded)
|
||||
if !strings.Contains(eml, "text/calendar") {
|
||||
t.Errorf("expected text/calendar in EML:\n%s", eml)
|
||||
}
|
||||
if !strings.Contains(eml, "Team Sync") {
|
||||
t.Errorf("expected event summary in ICS:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/ics"
|
||||
)
|
||||
|
||||
// MailDraftEdit is the `+draft-edit` shortcut: update an existing draft
|
||||
@@ -37,6 +38,11 @@ var MailDraftEdit = common.Shortcut{
|
||||
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
|
||||
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
|
||||
{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
|
||||
{Name: "set-event-summary", Desc: "Set calendar event title. Must be used together with --set-event-start and --set-event-end."},
|
||||
{Name: "set-event-start", Desc: "Set calendar event start time (ISO 8601)."},
|
||||
{Name: "set-event-end", Desc: "Set calendar event end time (ISO 8601)."},
|
||||
{Name: "set-event-location", Desc: "Set calendar event location."},
|
||||
{Name: "remove-event", Type: "bool", Desc: "Remove the calendar event from the draft."},
|
||||
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
|
||||
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."},
|
||||
},
|
||||
@@ -97,8 +103,9 @@ var MailDraftEdit = common.Shortcut{
|
||||
if err != nil {
|
||||
return output.ErrValidation("parse draft raw EML failed: %v", err)
|
||||
}
|
||||
// Pre-process insert_signature ops: resolve signature using the draft's
|
||||
// From address so alias/shared-mailbox senders get correct template vars.
|
||||
// Pre-process ops that need snapshot context: resolve signature using
|
||||
// the draft's From address, and build ICS for set_calendar using the
|
||||
// draft's From/To/Cc so organizer and attendee addresses are correct.
|
||||
var draftFromEmail string
|
||||
if len(snapshot.From) > 0 {
|
||||
draftFromEmail = snapshot.From[0].Address
|
||||
@@ -123,7 +130,8 @@ var MailDraftEdit = common.Shortcut{
|
||||
})
|
||||
}
|
||||
for i := range patch.Ops {
|
||||
if patch.Ops[i].Op == "insert_signature" {
|
||||
switch patch.Ops[i].Op {
|
||||
case "insert_signature":
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
@@ -132,6 +140,32 @@ var MailDraftEdit = common.Shortcut{
|
||||
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
|
||||
patch.Ops[i].SignatureImages = sigResult.Images
|
||||
}
|
||||
case "set_calendar":
|
||||
if calPart := draftpkg.FindPartByMediaType(snapshot.Body, "text/calendar"); calPart != nil {
|
||||
parsed := ics.ParseEvent(string(calPart.Body))
|
||||
if parsed == nil || !parsed.IsLarkDraft {
|
||||
return output.ErrValidation("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
|
||||
}
|
||||
}
|
||||
if _, _, err := parseEventTimeRange(patch.Ops[i].EventStart, patch.Ops[i].EventEnd); err != nil {
|
||||
return output.ErrValidation("set_calendar: %v", err)
|
||||
}
|
||||
// Derive effective To/Cc by replaying all pending recipient ops so
|
||||
// the ICS ATTENDEE list matches the final post-edit recipients.
|
||||
toAddrs, ccAddrs := effectiveRecipients(snapshot, patch.Ops)
|
||||
calData := buildCalendarBodyFromArgs(
|
||||
patch.Ops[i].EventSummary,
|
||||
patch.Ops[i].EventStart,
|
||||
patch.Ops[i].EventEnd,
|
||||
patch.Ops[i].EventLocation,
|
||||
draftFromEmail,
|
||||
joinAddresses(toAddrs),
|
||||
joinAddresses(ccAddrs),
|
||||
)
|
||||
if calData == nil {
|
||||
return output.ErrValidation("set_calendar: failed to build ICS from event fields")
|
||||
}
|
||||
patch.Ops[i].CalendarICS = calData
|
||||
}
|
||||
}
|
||||
// Pre-process add_attachment ops for large attachment support:
|
||||
@@ -346,6 +380,39 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
}
|
||||
}
|
||||
|
||||
// --set-event-* / --remove-event → set_calendar / remove_calendar op.
|
||||
// The ICS blob itself is pre-built at Execute time once the snapshot's
|
||||
// organizer/attendee addresses are available; here we only record the
|
||||
// user-supplied fields and validate the flag combination.
|
||||
hasEventSet := runtime.Str("set-event-summary") != ""
|
||||
hasEventRemove := runtime.Bool("remove-event")
|
||||
if !hasEventSet && (runtime.Str("set-event-start") != "" || runtime.Str("set-event-end") != "" || runtime.Str("set-event-location") != "") {
|
||||
return patch, output.ErrValidation("--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
|
||||
}
|
||||
if hasEventSet && hasEventRemove {
|
||||
return patch, output.ErrValidation("--set-event-summary and --remove-event are mutually exclusive")
|
||||
}
|
||||
if hasEventSet {
|
||||
summary := runtime.Str("set-event-summary")
|
||||
start := runtime.Str("set-event-start")
|
||||
end := runtime.Str("set-event-end")
|
||||
if summary == "" || start == "" || end == "" {
|
||||
return patch, output.ErrValidation("--set-event-summary, --set-event-start, and --set-event-end must all be provided together")
|
||||
}
|
||||
if _, _, err := parseEventTimeRange(start, end); err != nil {
|
||||
return patch, output.ErrValidation("%s", prefixEventRangeError("--set-event-", err).Error())
|
||||
}
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
|
||||
Op: "set_calendar",
|
||||
EventSummary: summary,
|
||||
EventStart: start,
|
||||
EventEnd: end,
|
||||
EventLocation: runtime.Str("set-event-location"),
|
||||
})
|
||||
} else if hasEventRemove {
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_calendar"})
|
||||
}
|
||||
|
||||
if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") {
|
||||
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
|
||||
}
|
||||
@@ -491,3 +558,45 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
"patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveRecipients returns the To and Cc address slices that will result
|
||||
// after all pending set_recipients / add_recipient / remove_recipient ops in
|
||||
// ops have been applied. Used by the set_calendar pre-processor to build ICS
|
||||
// with the correct post-edit ATTENDEE list before Apply() runs.
|
||||
func effectiveRecipients(snapshot *draftpkg.DraftSnapshot, ops []draftpkg.PatchOp) (to, cc []draftpkg.Address) {
|
||||
to = append([]draftpkg.Address{}, snapshot.To...)
|
||||
cc = append([]draftpkg.Address{}, snapshot.Cc...)
|
||||
|
||||
apply := func(addrs []draftpkg.Address, op draftpkg.PatchOp) []draftpkg.Address {
|
||||
switch op.Op {
|
||||
case "set_recipients":
|
||||
return append([]draftpkg.Address{}, op.Addresses...)
|
||||
case "add_recipient":
|
||||
for _, a := range addrs {
|
||||
if strings.EqualFold(a.Address, op.Address) {
|
||||
return addrs
|
||||
}
|
||||
}
|
||||
return append(addrs, draftpkg.Address{Name: op.Name, Address: op.Address})
|
||||
case "remove_recipient":
|
||||
next := addrs[:0:0]
|
||||
for _, a := range addrs {
|
||||
if !strings.EqualFold(a.Address, op.Address) {
|
||||
next = append(next, a)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
switch op.Field {
|
||||
case "to":
|
||||
to = apply(to, op)
|
||||
case "cc":
|
||||
cc = apply(cc, op)
|
||||
}
|
||||
}
|
||||
return to, cc
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
|
||||
for _, name := range []string{
|
||||
"set-subject", "set-to", "set-cc", "set-bcc",
|
||||
"set-priority", "patch-file",
|
||||
"set-event-summary", "set-event-start", "set-event-end", "set-event-location",
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
cmd.Flags().Bool("remove-event", false, "")
|
||||
for name, val := range flags {
|
||||
_ = cmd.Flags().Set(name, val)
|
||||
}
|
||||
@@ -115,3 +117,115 @@ func TestPrettyDraftAddresses(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetEventEmitsSetCalendarOp(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{
|
||||
"set-event-summary": "Team Sync",
|
||||
"set-event-start": "2026-05-10T10:00:00+08:00",
|
||||
"set-event-end": "2026-05-10T11:00:00+08:00",
|
||||
"set-event-location": "Room 301",
|
||||
})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d: %+v", len(patch.Ops), patch.Ops)
|
||||
}
|
||||
op := patch.Ops[0]
|
||||
if op.Op != "set_calendar" {
|
||||
t.Errorf("Op = %q, want set_calendar", op.Op)
|
||||
}
|
||||
if op.EventSummary != "Team Sync" {
|
||||
t.Errorf("EventSummary = %q, want Team Sync", op.EventSummary)
|
||||
}
|
||||
if op.EventLocation != "Room 301" {
|
||||
t.Errorf("EventLocation = %q, want Room 301", op.EventLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_RemoveEventEmitsRemoveCalendarOp(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{
|
||||
"remove-event": "true",
|
||||
})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 || patch.Ops[0].Op != "remove_calendar" {
|
||||
t.Fatalf("expected single remove_calendar op, got %+v", patch.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetAndRemoveEventMutuallyExclusive(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{
|
||||
"set-event-summary": "Meeting",
|
||||
"remove-event": "true",
|
||||
})
|
||||
_, err := buildDraftEditPatch(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --set-event-summary + --remove-event, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetEventMissingStartEnd(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{
|
||||
"set-event-summary": "Meeting",
|
||||
})
|
||||
_, err := buildDraftEditPatch(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --set-event-summary set without start/end, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRecipients_SetReplaces(t *testing.T) {
|
||||
snapshot := &draftpkg.DraftSnapshot{
|
||||
To: []draftpkg.Address{{Address: "old@example.com"}},
|
||||
Cc: []draftpkg.Address{{Address: "cc@example.com"}},
|
||||
}
|
||||
ops := []draftpkg.PatchOp{
|
||||
{Op: "set_recipients", Field: "to", Addresses: []draftpkg.Address{{Address: "new@example.com"}}},
|
||||
}
|
||||
to, cc := effectiveRecipients(snapshot, ops)
|
||||
if len(to) != 1 || to[0].Address != "new@example.com" {
|
||||
t.Errorf("expected to=[new@example.com], got %v", to)
|
||||
}
|
||||
if len(cc) != 1 || cc[0].Address != "cc@example.com" {
|
||||
t.Errorf("expected cc unchanged, got %v", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRecipients_AddAndRemove(t *testing.T) {
|
||||
snapshot := &draftpkg.DraftSnapshot{
|
||||
To: []draftpkg.Address{{Address: "alice@example.com"}, {Address: "bob@example.com"}},
|
||||
}
|
||||
ops := []draftpkg.PatchOp{
|
||||
{Op: "add_recipient", Field: "to", Address: "carol@example.com"},
|
||||
{Op: "remove_recipient", Field: "to", Address: "bob@example.com"},
|
||||
}
|
||||
to, _ := effectiveRecipients(snapshot, ops)
|
||||
if len(to) != 2 {
|
||||
t.Fatalf("expected 2 recipients, got %v", to)
|
||||
}
|
||||
addrs := map[string]bool{}
|
||||
for _, a := range to {
|
||||
addrs[a.Address] = true
|
||||
}
|
||||
if !addrs["alice@example.com"] || !addrs["carol@example.com"] || addrs["bob@example.com"] {
|
||||
t.Errorf("unexpected recipient set: %v", to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRecipients_NoOpsReturnsCopy(t *testing.T) {
|
||||
snapshot := &draftpkg.DraftSnapshot{
|
||||
To: []draftpkg.Address{{Address: "alice@example.com"}},
|
||||
Cc: []draftpkg.Address{{Address: "bob@example.com"}},
|
||||
}
|
||||
to, cc := effectiveRecipients(snapshot, nil)
|
||||
if len(to) != 1 || to[0].Address != "alice@example.com" {
|
||||
t.Errorf("unexpected to: %v", to)
|
||||
}
|
||||
if len(cc) != 1 || cc[0].Address != "bob@example.com" {
|
||||
t.Errorf("unexpected cc: %v", cc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ var MailForward = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
to := runtime.Str("to")
|
||||
@@ -74,6 +75,9 @@ var MailForward = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventSendTimeExclusion(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,6 +91,9 @@ var MailForward = common.Shortcut{
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -295,6 +302,11 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
if calData := buildCalendarBody(runtime, senderEmail, to, ccFlag); calData != nil {
|
||||
bld = bld.CalendarBody(calData)
|
||||
} else if len(sourceMsg.OriginalCalendarICS) > 0 {
|
||||
bld = bld.CalendarBody(sourceMsg.OriginalCalendarICS)
|
||||
}
|
||||
// Download original attachments, separating normal from large.
|
||||
type downloadedAtt struct {
|
||||
content []byte
|
||||
|
||||
@@ -41,7 +41,8 @@ var MailReply = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -75,12 +76,18 @@ var MailReply = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventSendTimeExclusion(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -287,6 +294,9 @@ var MailReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
if calData := buildCalendarBody(runtime, senderEmail, replyTo, ccFlag); calData != nil {
|
||||
bld = bld.CalendarBody(calData)
|
||||
}
|
||||
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
|
||||
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
|
||||
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes
|
||||
|
||||
@@ -42,7 +42,8 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -76,12 +77,18 @@ var MailReplyAll = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventSendTimeExclusion(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -296,6 +303,9 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
if calData := buildCalendarBody(runtime, senderEmail, toList, ccList); calData != nil {
|
||||
bld = bld.CalendarBody(calData)
|
||||
}
|
||||
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
|
||||
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
|
||||
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes
|
||||
|
||||
@@ -39,7 +39,8 @@ var MailSend = common.Shortcut{
|
||||
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
to := runtime.Str("to")
|
||||
subject := runtime.Str("subject")
|
||||
@@ -87,6 +88,9 @@ var MailSend = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateEventSendTimeExclusion(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,6 +100,9 @@ var MailSend = common.Shortcut{
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -249,6 +256,9 @@ var MailSend = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
if calData := buildCalendarBody(runtime, senderEmail, to, ccFlag); calData != nil {
|
||||
bld = bld.CalendarBody(calData)
|
||||
}
|
||||
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
|
||||
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
|
||||
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -213,3 +216,55 @@ func TestMailSendSaveDraftOutputsReference(t *testing.T) {
|
||||
t.Fatalf("reference = %v", data["reference"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailSend_WithCalendarEventEmbedded(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
||||
|
||||
draftsStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "draft_cal_001"},
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
|
||||
},
|
||||
})
|
||||
reg.Register(draftsStub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Team Sync",
|
||||
"--body", "<p>Please join us</p>",
|
||||
"--event-summary", "Team Sync",
|
||||
"--event-start", "2026-05-10T10:00+08:00",
|
||||
"--event-end", "2026-05-10T11:00+08:00",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mail send with calendar failed: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
raw, _ := reqBody["raw"].(string)
|
||||
decoded, decErr := base64.URLEncoding.DecodeString(raw)
|
||||
if decErr != nil {
|
||||
t.Fatalf("base64url decode: %v", decErr)
|
||||
}
|
||||
eml := string(decoded)
|
||||
if !strings.Contains(eml, "text/calendar") {
|
||||
t.Errorf("expected text/calendar in EML:\n%s", eml)
|
||||
}
|
||||
if !strings.Contains(eml, "method=REQUEST") {
|
||||
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
530
shortcuts/markdown/helpers.go
Normal file
530
shortcuts/markdown/helpers.go
Normal file
@@ -0,0 +1,530 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
|
||||
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
FolderToken string
|
||||
FilePath string
|
||||
Content string
|
||||
ContentSet bool
|
||||
FileSet bool
|
||||
}
|
||||
|
||||
type markdownUploadResult struct {
|
||||
FileToken string
|
||||
Version string
|
||||
}
|
||||
|
||||
type markdownMultipartSession struct {
|
||||
UploadID string
|
||||
BlockSize int64
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
return common.FlagErrorf("--content and --file are mutually exclusive")
|
||||
case !spec.ContentSet && !spec.FileSet:
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
}
|
||||
|
||||
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
return common.FlagErrorf("--name is required when using --content")
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileSet {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileName != "" {
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return common.FlagErrorf("%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
|
||||
return common.FlagErrorf("%s must end with .md", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalMarkdownFileName(spec markdownUploadSpec) string {
|
||||
if strings.TrimSpace(spec.FileName) != "" {
|
||||
return strings.TrimSpace(spec.FileName)
|
||||
}
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(spec.FilePath)
|
||||
}
|
||||
|
||||
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
|
||||
var size int64
|
||||
if spec.ContentSet {
|
||||
size = int64(len(spec.Content))
|
||||
} else {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return 0, common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, common.WrapInputStatError(err)
|
||||
}
|
||||
size = info.Size()
|
||||
}
|
||||
if size == 0 {
|
||||
return 0, output.ErrValidation("%s", markdownEmptyContentError)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
}
|
||||
return "<markdown content>"
|
||||
}
|
||||
|
||||
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
|
||||
if !multipart {
|
||||
body := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
body["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
desc := "multipart/form-data upload"
|
||||
if spec.FileToken != "" {
|
||||
desc = "multipart/form-data overwrite upload"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/upload_all").
|
||||
Body(body)
|
||||
}
|
||||
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
desc := "3-step multipart upload"
|
||||
if spec.FileToken != "" {
|
||||
desc = "3-step multipart overwrite upload"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/upload_prepare").
|
||||
Desc("[1] Initialize multipart upload").
|
||||
Body(prepareBody).
|
||||
POST("/open-apis/drive/v1/files/upload_part").
|
||||
Desc("[2] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_finish").
|
||||
Desc("[3] Finalize upload and get file_token/version").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
}
|
||||
|
||||
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = finalMarkdownFileName(spec)
|
||||
}
|
||||
if fileName != "" {
|
||||
spec.FileName = fileName
|
||||
return markdownUploadDryRun(spec, fileSize, multipart)
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().Desc("Fetch the existing file name, then overwrite the file content")
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[1] Read current file metadata to preserve the existing file name").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": spec.FileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec.FileName = "<existing_remote_name_or_" + spec.FileToken + ".md>"
|
||||
if !multipart {
|
||||
dry.POST("/open-apis/drive/v1/files/upload_all").
|
||||
Desc("[2] Overwrite file contents with multipart/form-data upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
"file_token": spec.FileToken,
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
dry.POST("/open-apis/drive/v1/files/upload_prepare").
|
||||
Desc("[2] Initialize multipart overwrite upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_part").
|
||||
Desc("[3] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_finish").
|
||||
Desc("[4] Finalize upload and get file_token/version").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
fileSize := int64(len(payload))
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
}
|
||||
|
||||
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
f, err := runtime.FileIO().Open(spec.FilePath)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "explorer")
|
||||
fd.AddField("parent_node", spec.FolderToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
session, err := parseMarkdownMultipartSession(prepareResult)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
|
||||
|
||||
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
}
|
||||
|
||||
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
|
||||
session := markdownMultipartSession{
|
||||
UploadID: common.GetString(data, "upload_id"),
|
||||
BlockSize: int64(common.GetFloat(data, "block_size")),
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
|
||||
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
session.UploadID, session.BlockSize, session.BlockNum)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
|
||||
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
|
||||
if session.BlockNum != expectedBlocks {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
|
||||
session.BlockSize,
|
||||
session.BlockNum,
|
||||
expectedBlocks,
|
||||
payloadSize,
|
||||
)
|
||||
}
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if session.BlockSize > maxInt {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
|
||||
buffer := make([]byte, int(session.BlockSize))
|
||||
remaining := payloadSize
|
||||
|
||||
for seq := 0; seq < session.BlockNum; seq++ {
|
||||
chunkSize := session.BlockSize
|
||||
if remaining > 0 && chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
|
||||
}
|
||||
|
||||
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, common.FormatSize(int64(n)))
|
||||
remaining -= int64(n)
|
||||
}
|
||||
if remaining != 0 {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
|
||||
remaining,
|
||||
session.BlockNum,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool) (markdownUploadResult, error) {
|
||||
result := markdownUploadResult{
|
||||
FileToken: common.GetString(data, "file_token"),
|
||||
Version: common.GetString(data, "version"),
|
||||
}
|
||||
if result.Version == "" {
|
||||
result.Version = common.GetString(data, "data_version")
|
||||
}
|
||||
if result.FileToken == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
if requireVersion && result.Version == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
version = common.GetString(data, "data_version")
|
||||
}
|
||||
if version != "" {
|
||||
fmt.Fprintf(w, "version: %s\n", version)
|
||||
}
|
||||
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
|
||||
if grant := common.GetMap(data, "permission_grant"); grant != nil {
|
||||
fmt.Fprintf(w, "permission_grant.status: %s\n", common.GetString(grant, "status"))
|
||||
fmt.Fprintf(w, "permission_grant.perm: %s\n", common.GetString(grant, "perm"))
|
||||
}
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownSavedFile(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path"))
|
||||
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownContent(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprint(w, common.GetString(data, "content"))
|
||||
}
|
||||
|
||||
func fileNameFromDownloadHeader(header http.Header, fallback string) string {
|
||||
name := fallback
|
||||
if header != nil {
|
||||
if headerName := larkcore.FileNameByHeader(header); strings.TrimSpace(headerName) != "" {
|
||||
name = headerName
|
||||
}
|
||||
}
|
||||
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
||||
name = path.Base(name)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
91
shortcuts/markdown/markdown_create.go
Normal file
91
shortcuts/markdown/markdown_create.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownCreate = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+create",
|
||||
Description: "Create a Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
|
||||
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
|
||||
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, true)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result markdownUploadResult
|
||||
if spec.FileSet {
|
||||
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
|
||||
} else {
|
||||
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": result.FileToken,
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownWrite(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
131
shortcuts/markdown/markdown_fetch.go
Normal file
131
shortcuts/markdown/markdown_fetch.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"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/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownFetch = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+fetch",
|
||||
Description: "Fetch a Markdown file from Drive",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Markdown file token", Required: true},
|
||||
{Name: "output", Desc: "local save path or directory; omit to return content directly"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing local output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := validate.SafeOutputPath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Set("file_token", runtime.Str("file-token"))
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath)
|
||||
} else {
|
||||
dry.Set("output", "<stdout>")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fileName := fileNameFromDownloadHeader(resp.Header, fileToken+".md")
|
||||
if outputPath == "" {
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"content": string(payload),
|
||||
"size_bytes": len(payload),
|
||||
}
|
||||
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownContent(w, out)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if markdownFetchOutputIsDirectory(runtime, outputPath) {
|
||||
outputPath = filepath.Join(outputPath, fileName)
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
if savedPath == "" {
|
||||
savedPath = outputPath
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownSavedFile(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func markdownFetchOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
113
shortcuts/markdown/markdown_overwrite.go
Normal file
113
shortcuts/markdown/markdown_overwrite.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownOverwrite = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+overwrite",
|
||||
Description: "Overwrite an existing Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "target Markdown file token", Required: true},
|
||||
{Name: "name", Desc: "optional file name with .md suffix; overrides the existing/local file name"},
|
||||
{Name: "content", Desc: "new Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, false)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownOverwriteDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
spec := markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = filepath.Base(spec.FilePath)
|
||||
}
|
||||
if fileName == "" {
|
||||
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName = strings.TrimSpace(remoteName)
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = fileToken + ".md"
|
||||
}
|
||||
spec.FileName = fileName
|
||||
|
||||
var result markdownUploadResult
|
||||
if spec.FileSet {
|
||||
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
|
||||
} else {
|
||||
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": result.FileToken,
|
||||
"file_name": fileName,
|
||||
"version": result.Version,
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownWrite(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
1344
shortcuts/markdown/markdown_test.go
Normal file
1344
shortcuts/markdown/markdown_test.go
Normal file
File diff suppressed because it is too large
Load Diff
15
shortcuts/markdown/shortcuts.go
Normal file
15
shortcuts/markdown/shortcuts.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all markdown shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownFetch,
|
||||
MarkdownOverwrite,
|
||||
}
|
||||
}
|
||||
72
shortcuts/minutes/minutes_upload.go
Normal file
72
shortcuts/minutes/minutes_upload.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesUploadSupportedFormatsTip = "Supported audio formats: wav, mp3, m4a, aac, ogg, wma, amr; supported video formats: avi, wmv, mov, mp4, m4v, mpeg, ogg, flv."
|
||||
minutesUploadLimitsTip = "The original uploaded media must be no larger than 6GB and no longer than 6 hours."
|
||||
)
|
||||
|
||||
// MinutesUpload uploads a media file token to generate a minute.
|
||||
var MinutesUpload = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+upload",
|
||||
Description: "Upload a media file token to generate a minute",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:minutes.upload:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file_token of a supported audio/video file already uploaded to Drive", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"This shortcut only accepts --file-token. Upload the local media file to Drive first with `lark-cli drive +upload`.",
|
||||
minutesUploadSupportedFormatsTip,
|
||||
minutesUploadLimitsTip,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
if fileToken == "" {
|
||||
return output.ErrValidation("--file-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/minutes/v1/minutes/upload").
|
||||
Body(map[string]interface{}{"file_token": runtime.Str("file-token")})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
minuteURL := common.GetString(data, "minute_url")
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"minute_url": minuteURL,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
119
shortcuts/minutes/minutes_upload_test.go
Normal file
119
shortcuts/minutes/minutes_upload_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestMinutesUpload_Validate(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing file token",
|
||||
args: []string{"+upload", "--as", "user"},
|
||||
wantErr: "required flag(s) \"file-token\" not set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesUpload.Mount(parent, f)
|
||||
parent.SetArgs(tt.args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_HelpMetadata(t *testing.T) {
|
||||
if len(MinutesUpload.Flags) == 0 {
|
||||
t.Fatal("expected file-token flag metadata")
|
||||
}
|
||||
if got := MinutesUpload.Flags[0].Desc; !strings.Contains(got, "supported audio/video file") {
|
||||
t.Fatalf("file-token description = %q, want supported media guidance", got)
|
||||
}
|
||||
|
||||
joinedTips := strings.Join(MinutesUpload.Tips, "\n")
|
||||
for _, want := range []string{
|
||||
"drive +upload",
|
||||
"wav, mp3, m4a, aac, ogg, wma, amr",
|
||||
"avi, wmv, mov, mp4, m4v, mpeg, ogg, flv",
|
||||
"6GB",
|
||||
"6 hours",
|
||||
} {
|
||||
if !strings.Contains(joinedTips, want) {
|
||||
t.Fatalf("tips should contain %q, got:\n%s", want, joinedTips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/upload") {
|
||||
t.Errorf("expected POST /open-apis/minutes/v1/minutes/upload, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "boxcn123456") {
|
||||
t.Errorf("expected file token in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_Execute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPost,
|
||||
URL: "/open-apis/minutes/v1/minutes/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"minute_url": "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--format", "json", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
|
||||
dataMap, _ := res["data"].(map[string]interface{})
|
||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesSearch,
|
||||
MinutesDownload,
|
||||
MinutesUpload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/event"
|
||||
"github.com/larksuite/cli/shortcuts/im"
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
@@ -42,6 +43,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
@@ -90,6 +92,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
}
|
||||
program.AddCommand(svc)
|
||||
}
|
||||
if service == "docs" {
|
||||
doc.ConfigureServiceHelp(svc)
|
||||
}
|
||||
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
|
||||
30
shortcuts/register_markdown_test.go
Normal file
30
shortcuts/register_markdown_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"markdown", "+create"},
|
||||
{"markdown", "+fetch"},
|
||||
{"markdown", "+overwrite"},
|
||||
} {
|
||||
cmd, _, err := program.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("find markdown shortcut %v: %v", path, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != path[1] {
|
||||
t.Fatalf("markdown shortcut not mounted: %#v", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,45 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRegisterTestFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
return f
|
||||
}
|
||||
|
||||
func newRegisterTestProgramWithTipsHelp() *cobra.Command {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
defaultHelp := program.HelpFunc()
|
||||
program.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
defaultHelp(cmd, args)
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
return
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range tips {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
return program
|
||||
}
|
||||
|
||||
func TestAllShortcutsScopesNotNil(t *testing.T) {
|
||||
for _, s := range allShortcuts {
|
||||
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
|
||||
@@ -48,7 +77,7 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
|
||||
|
||||
func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCmd, _, err := program.Find([]string{"base"})
|
||||
if err != nil {
|
||||
@@ -69,7 +98,7 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
|
||||
func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
previewCmd, _, err := program.Find([]string{"docs", "+media-preview"})
|
||||
if err != nil {
|
||||
@@ -80,12 +109,182 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
docsCmd, _, err := program.Find([]string{"docs"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs command: %v", err)
|
||||
}
|
||||
if docsCmd == nil || docsCmd.Name() != "docs" {
|
||||
t.Fatalf("docs command not mounted: %#v", docsCmd)
|
||||
}
|
||||
if docsCmd.Flags().Lookup("api-version") == nil {
|
||||
t.Fatal("docs command should expose --api-version for versioned help")
|
||||
}
|
||||
|
||||
if !strings.Contains(docsCmd.Long, "Document and content operations.") {
|
||||
t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long)
|
||||
}
|
||||
|
||||
var defaultHelp bytes.Buffer
|
||||
docsCmd.SetOut(&defaultHelp)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs help failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(defaultHelp.String(), want) {
|
||||
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
docsCmd, _, err := program.Find([]string{"docs"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs command: %v", err)
|
||||
}
|
||||
if err := docsCmd.Flags().Set("api-version", "v2"); err != nil {
|
||||
t.Fatalf("set docs api-version: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
docsCmd.SetOut(&out)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs v2 help failed: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"Document and content operations (v2).",
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
apiVersion string
|
||||
shortcutHelp string
|
||||
versionedFlag string
|
||||
}{
|
||||
{
|
||||
name: "create v1",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--markdown",
|
||||
},
|
||||
{
|
||||
name: "create v2",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--content",
|
||||
},
|
||||
{
|
||||
name: "fetch v1",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "--offset",
|
||||
},
|
||||
{
|
||||
name: "fetch v2",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "partial read scope",
|
||||
},
|
||||
{
|
||||
name: "update v1",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--mode",
|
||||
},
|
||||
{
|
||||
name: "update v2",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--command",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
program := newRegisterTestProgramWithTipsHelp()
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
cmd, _, err := program.Find([]string{"docs", tt.shortcut})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s command: %v", tt.shortcut, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != tt.shortcut {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
|
||||
}
|
||||
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
|
||||
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Help(); err != nil {
|
||||
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
tt.shortcutHelp,
|
||||
tt.versionedFlag,
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"[NOTE]",
|
||||
"Use --api-version v2 for the latest API",
|
||||
} {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
existingBase := &cobra.Command{Use: "base", Short: "existing base service"}
|
||||
program.AddCommand(existingBase)
|
||||
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCount := 0
|
||||
for _, command := range program.Commands() {
|
||||
|
||||
@@ -17,6 +17,21 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
func inferTaskMemberType(id string) string {
|
||||
if strings.HasPrefix(strings.TrimSpace(id), "cli_") {
|
||||
return "app"
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
||||
func buildTaskMember(id, role string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"role": role,
|
||||
"type": inferTaskMemberType(id),
|
||||
}
|
||||
}
|
||||
|
||||
// parseTaskTime converts a flexible time string into the Task API due/start object format.
|
||||
func parseTaskTime(timeStr string) (map[string]interface{}, error) {
|
||||
var msTs string
|
||||
@@ -96,14 +111,15 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
body["description"] = desc
|
||||
}
|
||||
|
||||
var members []map[string]interface{}
|
||||
if assignee := runtime.Str("assignee"); assignee != "" {
|
||||
body["members"] = []map[string]interface{}{
|
||||
{
|
||||
"id": assignee,
|
||||
"role": "assignee",
|
||||
"type": "user",
|
||||
},
|
||||
}
|
||||
members = append(members, buildTaskMember(assignee, "assignee"))
|
||||
}
|
||||
if follower := runtime.Str("follower"); follower != "" {
|
||||
members = append(members, buildTaskMember(follower, "follower"))
|
||||
}
|
||||
if len(members) > 0 {
|
||||
body["members"] = members
|
||||
}
|
||||
|
||||
if tasklistId := runtime.Str("tasklist-id"); tasklistId != "" {
|
||||
@@ -147,7 +163,8 @@ var CreateTask = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "summary", Desc: "task title"},
|
||||
{Name: "description", Desc: "task description"},
|
||||
{Name: "assignee", Desc: "assignee open_id"},
|
||||
{Name: "assignee", Desc: "task assignee id added during create; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
|
||||
{Name: "follower", Desc: "task follower id added during create; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
|
||||
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
|
||||
{Name: "tasklist-id", Desc: "tasklist id or applink URL"},
|
||||
{Name: "idempotency-key", Desc: "client token for idempotency"},
|
||||
|
||||
@@ -28,8 +28,8 @@ var AssignTask = common.Shortcut{
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "add", Desc: "comma-separated open_ids to add as assignees"},
|
||||
{Name: "remove", Desc: "comma-separated open_ids to remove from assignees"},
|
||||
{Name: "add", Desc: "comma-separated assignee IDs to add; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
|
||||
{Name: "remove", Desc: "comma-separated assignee IDs to remove; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
|
||||
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
|
||||
},
|
||||
|
||||
@@ -43,16 +43,15 @@ var AssignTask = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildMembersBody(addStr, runtime.Str("idempotency-key"))
|
||||
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
|
||||
d.POST("/open-apis/task/v2/tasks/" + taskId + "/add_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildMembersBody(removeStr, "")
|
||||
body := buildMembersBody(removeStr, "assignee", "")
|
||||
d.POST("/open-apis/task/v2/tasks/" + taskId + "/remove_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
@@ -69,7 +68,7 @@ var AssignTask = common.Shortcut{
|
||||
var lastData map[string]interface{}
|
||||
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildMembersBody(addStr, runtime.Str("idempotency-key"))
|
||||
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
|
||||
@@ -92,7 +91,7 @@ var AssignTask = common.Shortcut{
|
||||
}
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildMembersBody(removeStr, "")
|
||||
body := buildMembersBody(removeStr, "assignee", "")
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
|
||||
@@ -125,21 +124,21 @@ var AssignTask = common.Shortcut{
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task assignees updated successfully!\n")
|
||||
fmt.Fprintf(w, "✅ Task assignes updated successfully!\n")
|
||||
fmt.Fprintf(w, "Task ID: %s\n", taskId)
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
|
||||
if members, ok := task["members"].([]interface{}); ok {
|
||||
fmt.Fprintf(w, "Current Assignees: %d\n", len(members))
|
||||
fmt.Fprintf(w, "Current Assignes: %d\n", len(members))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildMembersBody(idsStr string, clientToken string) map[string]interface{} {
|
||||
func buildMembersBody(idsStr, role, clientToken string) map[string]interface{} {
|
||||
ids := strings.Split(idsStr, ",")
|
||||
var members []map[string]interface{}
|
||||
|
||||
@@ -148,11 +147,7 @@ func buildMembersBody(idsStr string, clientToken string) map[string]interface{}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
members = append(members, map[string]interface{}{
|
||||
"id": id,
|
||||
"role": "assignee",
|
||||
"type": "user",
|
||||
})
|
||||
members = append(members, buildTaskMember(id, role))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
||||
@@ -6,14 +6,74 @@ package task
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestBuildMembersBody(t *testing.T) {
|
||||
convey.Convey("Build with ids and token", t, func() {
|
||||
body := buildMembersBody("u1, u2 , ", "token1")
|
||||
body := buildMembersBody("u1, u2 , ", "assignee", "token1")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(body["client_token"], convey.ShouldEqual, "token1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers app assignee members from cli prefix", t, func() {
|
||||
body := buildMembersBody("cli_bot_1", "assignee", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 1)
|
||||
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers mixed member types in one list", t, func() {
|
||||
body := buildMembersBody("ou_user_1, cli_bot_1", "assignee", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
convey.So(members[1]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildTaskCreateBodySupportsAssigneeAndFollower(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("summary", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("tasklist-id", "", "")
|
||||
cmd.Flags().String("idempotency-key", "", "")
|
||||
cmd.Flags().String("data", "", "")
|
||||
_ = cmd.Flags().Set("summary", "bot task")
|
||||
_ = cmd.Flags().Set("assignee", "cli_bot_xxx")
|
||||
_ = cmd.Flags().Set("follower", "ou_follower_xxx")
|
||||
|
||||
runtime := &common.RuntimeContext{Cmd: cmd}
|
||||
body, err := buildTaskCreateBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTaskCreateBody() error = %v", err)
|
||||
}
|
||||
|
||||
members := body["members"].([]map[string]interface{})
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("members len = %d, want 2", len(members))
|
||||
}
|
||||
if got := members[0]["type"]; got != "app" {
|
||||
t.Fatalf("member[0] type = %v, want app", got)
|
||||
}
|
||||
if got := members[0]["role"]; got != "assignee" {
|
||||
t.Fatalf("member[0] role = %v, want assignee", got)
|
||||
}
|
||||
if got := members[1]["type"]; got != "user" {
|
||||
t.Fatalf("member[1] type = %v, want user", got)
|
||||
}
|
||||
if got := members[1]["role"]; got != "follower" {
|
||||
t.Fatalf("member[1] role = %v, want follower", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ var FollowersTask = common.Shortcut{
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "add", Desc: "comma-separated open_ids to add as followers"},
|
||||
{Name: "remove", Desc: "comma-separated open_ids to remove from followers"},
|
||||
{Name: "add", Desc: "comma-separated follower IDs to add; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
|
||||
{Name: "remove", Desc: "comma-separated follower IDs to remove; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
|
||||
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
|
||||
},
|
||||
|
||||
@@ -144,11 +144,7 @@ func buildFollowersBody(idsStr string, clientToken string) map[string]interface{
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
members = append(members, map[string]interface{}{
|
||||
"id": id,
|
||||
"role": "follower",
|
||||
"type": "user",
|
||||
})
|
||||
members = append(members, buildTaskMember(id, "follower"))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
||||
@@ -15,5 +15,24 @@ func TestBuildFollowersBody(t *testing.T) {
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(body["client_token"], convey.ShouldEqual, "token1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers app followers", t, func() {
|
||||
body := buildFollowersBody("cli_bot_1", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 1)
|
||||
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers mixed follower types in one list", t, func() {
|
||||
body := buildFollowersBody("ou_user_1, cli_bot_1", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
convey.So(members[1]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- `lark-cli base records create` ❌
|
||||
2. **优先使用 Shortcut** — 有 Shortcut 的操作不要手拼原生 API
|
||||
3. **写记录前** — 先调用 `table.fields list` 获取字段 `type/ui_type`,再读 [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md);该文档是 CellValue 的 source of truth
|
||||
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型的 `property` 结构
|
||||
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型 JSON 结构
|
||||
5. **筛选查询前** — 先读 [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md),当前 `base/v3` 通过 `view.filter update + table.records list` 组合完成筛选读取
|
||||
6. **批量上限 200 条/次** — 同一表建议串行写入,并在批次间延迟 0.5–1 秒
|
||||
7. **改名和删除按明确意图执行** — 视图重命名这类低风险改名操作,目标和新名称明确时可直接执行;删除记录 / 字段 / 表时,只要用户已经明确要求删除且目标明确,也可直接执行,不需要再补一次确认
|
||||
|
||||
@@ -236,6 +236,34 @@ lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
|
||||
- 需要同时授权 mail 和 im 两个域的 scope
|
||||
- 分享的卡片包含邮件摘要信息,收件人可点击查看
|
||||
|
||||
### 发送日程邀请邮件
|
||||
|
||||
在邮件中嵌入日程邀请(`text/calendar`),收件人收信后可直接接受或拒绝日程。`To`/`Cc` 收件人自动成为参会人(ATTENDEE),发件人自动成为组织者(ORGANIZER)。
|
||||
|
||||
```bash
|
||||
# 发送带日程邀请的新邮件(先保存草稿,确认后发送)
|
||||
lark-cli mail +send --as user \
|
||||
--to alice@example.com --cc bob@example.com \
|
||||
--subject '产品评审' \
|
||||
--body '<p>请参加本次产品评审会议。</p>' \
|
||||
--event-summary '产品评审' \
|
||||
--event-start '2026-05-10T14:00+08:00' \
|
||||
--event-end '2026-05-10T15:00+08:00' \
|
||||
--event-location '5F 大会议室' \
|
||||
--confirm-send
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `--event-summary`:日程标题,设置此参数即开启日程邀请模式,需同时设置 `--event-start` 和 `--event-end`
|
||||
- `--event-start` / `--event-end`:ISO 8601 格式时间,如 `2026-05-10T14:00+08:00`
|
||||
- `--event-location`:可选,日程地点
|
||||
|
||||
**约束:**
|
||||
- `--event-*` 与 `--send-time`(定时发送)互斥,不可同时使用
|
||||
- `Bcc` 收件人不会成为日程参会人;如果邮件同时包含 Bcc 和日程,后端在发送时会拒绝该请求
|
||||
|
||||
读取含日程邀请的邮件时,`calendar_event` 字段包含日程详情(`method`、`summary`、`start`、`end`、`organizer`、`attendees` 等)。
|
||||
|
||||
### 正文格式:优先使用 HTML
|
||||
|
||||
撰写邮件正文时,**默认使用 HTML 格式**(body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.0
|
||||
description: "当需要用 lark-cli 操作飞书多维表格(Base)时调用:适用于建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
|
||||
description: "当需要用 lark-cli 操作飞书多维表格(Base)时调用:搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -42,6 +42,7 @@ metadata:
|
||||
3. 定位到命令后,先读该命令对应的 reference,再执行命令。
|
||||
4. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
6. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` 搜索 `BITABLE` 资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md):标题精确匹配、限定创建者/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
|
||||
|
||||
## 2. 模块与命令导航
|
||||
|
||||
@@ -67,6 +68,7 @@ metadata:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
|
||||
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;`--folder-token`、`--time-zone` 都是可选项 |
|
||||
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
|
||||
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;复制成功后应主动返回新 Base 标识信息 |
|
||||
@@ -102,7 +104,7 @@ metadata:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-search.md`](references/lark-base-record-search.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md)、[`lark-base-record-get.md`](references/lark-base-record-get.md) | 默认优先 `+record-list`;仅当用户提供明确搜索关键词时使用 `+record-search`;取数不用来做聚合分析;`--limit` 最大 `200`;仅在用户明确需要时继续翻页;`+record-list` 只能串行执行 |
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query` |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
@@ -117,7 +119,7 @@ metadata:
|
||||
|------|------------------|----------------|----------|
|
||||
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
|
||||
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
|
||||
@@ -295,7 +297,7 @@ lark-cli auth login --domain base
|
||||
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`。
|
||||
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
|
||||
9. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
10. 统一使用 `--base-token`,不使用旧 `--app-token`。
|
||||
10. 统一使用 `--base-token`。
|
||||
11. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
12. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
13. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
|
||||
@@ -22,16 +22,16 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
|
||||
## 字段类型与操作符速查(AI 决策用)
|
||||
|
||||
> `+field-list` 返回的 `type` 字段映射:number(数字)、text(文本)、select(单选)、multi_select(多选)、datetime(日期时间)、checkbox(复选框)、user(人员)
|
||||
> 先用 `+field-list` / `+field-get` 确认字段 `type`;本节使用当前字段接口里的 canonical 类型名:`number`、`text`、`select`、`datetime`、`checkbox`、`user`。
|
||||
|
||||
```
|
||||
文本/电话/URL/邮箱: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
|
||||
数字/货币/进度: is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
|
||||
单选: is, isNot, isEmpty, isNotEmpty
|
||||
多选: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
|
||||
日期/时间: is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
|
||||
复选框: is (value: true/false)
|
||||
人员/创建人/修改人: is, isNot, isEmpty, isNotEmpty
|
||||
text: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
|
||||
number: is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
|
||||
select(multiple=false): is, isNot, isEmpty, isNotEmpty
|
||||
select(multiple=true): is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
|
||||
datetime: is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
|
||||
checkbox: is (value: true/false)
|
||||
user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty
|
||||
```
|
||||
|
||||
## data_config 通用结构
|
||||
@@ -148,13 +148,13 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
|
||||
| 字段类型 | value 类型 | 适用操作符 | 示例 |
|
||||
|----------|-----------|-----------|------|
|
||||
| 文本 / 电话 / URL | string | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | `{"field_name":"姓名","operator":"contains","value":"张"}` |
|
||||
| 数字 | number | is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"金额","operator":"isGreater","value":0}` |
|
||||
| 单选 | string(选项名) | is, isNot, isEmpty, isNotEmpty | `{"field_name":"状态","operator":"is","value":"已完成"}` |
|
||||
| 多选 | string[](选多个)/ string(选单个) | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | 多选传数组如 `["标签1","标签2"]`;单选传单个字符串 |
|
||||
| 日期时间 / 创建时间 / 修改时间 | number(Unix 毫秒时间戳,13位) | is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"创建日期","operator":"isGreater","value":1704038400000}` |
|
||||
| 复选框 | boolean | is | `{"field_name":"已审核","operator":"is","value":true}` |
|
||||
| 人员 / 创建人 / 修改人 | string 或 string[](用户 ID,格式 `ou_xxx`) | is, isNot, isEmpty, isNotEmpty | `{"field_name":"负责人","operator":"is","value":"ou_xxxxxxxxxxxxxxxx"}` |
|
||||
| `text` | string | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | `{"field_name":"姓名","operator":"contains","value":"张"}` |
|
||||
| `number` | number | is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"金额","operator":"isGreater","value":0}` |
|
||||
| `select` (`multiple=false`) | string(选项名) | is, isNot, isEmpty, isNotEmpty | `{"field_name":"状态","operator":"is","value":"已完成"}` |
|
||||
| `select` (`multiple=true`) | string[](选多个)/ string(选单个) | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | 多选传数组如 `["标签1","标签2"]`;单选传单个字符串 |
|
||||
| `datetime` / `created_at` / `updated_at` | number(Unix 毫秒时间戳,13位) | is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"创建日期","operator":"isGreater","value":1704038400000}` |
|
||||
| `checkbox` | boolean | is | `{"field_name":"已审核","operator":"is","value":true}` |
|
||||
| `user` / `created_by` / `updated_by` | string 或 string[](用户 ID,格式 `ou_xxx`) | is, isNot, isEmpty, isNotEmpty | `{"field_name":"负责人","operator":"is","value":"ou_xxxxxxxxxxxxxxxx"}` |
|
||||
| 所有类型(为空/不为空) | 不需要 value | isEmpty, isNotEmpty | `{"field_name":"备注","operator":"isEmpty"}` |
|
||||
|
||||
> `value` 类型为 `string | number | boolean | string[]`,需根据字段类型匹配正确格式
|
||||
@@ -268,7 +268,7 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
|
||||
{
|
||||
"table_name": "表名",
|
||||
"series": [{ "field_name": "数值字段", "rollup": "SUM" }],
|
||||
"group_by": [{ "field_name": "阶段字段", "mode": "integrated" }]
|
||||
"group_by": [{ "field_name": "状态字段", "mode": "integrated" }]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 飞书多维表格使用场景完整示例(base)
|
||||
|
||||
本文档提供基于 `lark-cli base ...` 的完整示例,覆盖 unified Shortcut 与当前 `base/v3` 原生 API 的常见组合方式。
|
||||
本文档提供基于 `lark-cli base +...` shortcut 的完整示例。
|
||||
|
||||
> **返回**: [SKILL.md](../SKILL.md) | **参考**: [shortcut 字段 JSON 规范](lark-base-shortcut-field-properties.md) · [CellValue 规范](lark-base-cell-value.md)
|
||||
|
||||
@@ -15,35 +15,37 @@ lark-cli base +table-create \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--name "客户管理表" \
|
||||
--fields '[
|
||||
{"name":"客户名称","type":"text"},
|
||||
{"name":"负责人","type":"user","property":{"multiple":false}},
|
||||
{"name":"客户名称","type":"text","description":"主标题字段"},
|
||||
{"name":"负责人","type":"user","multiple":false,"description":"用于标记客户跟进的直接负责人"},
|
||||
{"name":"签约日期","type":"datetime"},
|
||||
{"name":"状态","type":"single_select","property":{"options":["进行中","已完成"]}}
|
||||
{"name":"状态","type":"select","multiple":false,"options":[{"name":"进行中"},{"name":"已完成"}]}
|
||||
]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景 2:使用原生 API 创建数据表并查看字段
|
||||
## 场景 2:创建数据表并查看字段
|
||||
|
||||
适合需要精确观察 `base/v3` 请求参数和响应结构的场景。原生 API 直接使用 `base` service。
|
||||
适合需要先建表、再确认字段结构的场景。
|
||||
|
||||
### 步骤 1:在已有 Base 中创建数据表
|
||||
|
||||
```bash
|
||||
lark-cli base tables create \
|
||||
--params '{"base_token":"bascnXXXXXXXX"}' \
|
||||
--data '{"name":"客户管理表"}'
|
||||
lark-cli base +table-create \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--name "客户管理表"
|
||||
```
|
||||
|
||||
### 步骤 2:列出字段
|
||||
|
||||
```bash
|
||||
lark-cli base table.fields list \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
|
||||
lark-cli base +field-list \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--limit 100
|
||||
```
|
||||
|
||||
> 提示:当前 `base/v3` 不再使用旧的 `app_token` / `app.table.*` 路径,统一改为 `base_token` + `tables` / `table.fields` / `table.records`。
|
||||
> 提示:Base token 统一通过 `--base-token` 传入;表 ID 统一通过 `--table-id` 传入。
|
||||
|
||||
---
|
||||
|
||||
@@ -52,9 +54,10 @@ lark-cli base table.fields list \
|
||||
### 新增记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records create \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX"}' \
|
||||
--data '{
|
||||
lark-cli base +record-upsert \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--json '{
|
||||
"客户名称":"字节跳动",
|
||||
"负责人":[{"id":"ou_xxx"}],
|
||||
"状态":"进行中"
|
||||
@@ -64,16 +67,20 @@ lark-cli base table.records create \
|
||||
### 列出记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records list \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
|
||||
lark-cli base +record-list \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--limit 100
|
||||
```
|
||||
|
||||
### 更新记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records patch \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}' \
|
||||
--data '{
|
||||
lark-cli base +record-upsert \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--record-id recXXXXXXXX \
|
||||
--json '{
|
||||
"状态":"已完成"
|
||||
}'
|
||||
```
|
||||
@@ -81,22 +88,27 @@ lark-cli base table.records patch \
|
||||
### 删除记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records delete \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}'
|
||||
lark-cli base +record-delete \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--record-id recXXXXXXXX \
|
||||
--yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景 4:配置视图筛选后按视图读取记录
|
||||
|
||||
当前 `base/v3` 原生 spec 没有独立 `search` 方法。需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
|
||||
需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
|
||||
|
||||
### 更新视图筛选条件
|
||||
|
||||
```bash
|
||||
lark-cli base view.filter update \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX"}' \
|
||||
--data '{
|
||||
lark-cli base +view-set-filter \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--view-id vewXXXXXXXX \
|
||||
--json '{
|
||||
"logic":"and",
|
||||
"conditions":[
|
||||
{
|
||||
@@ -111,8 +123,11 @@ lark-cli base view.filter update \
|
||||
### 按视图读取记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records list \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX","limit":100}'
|
||||
lark-cli base +record-list \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--view-id vewXXXXXXXX \
|
||||
--limit 100
|
||||
```
|
||||
|
||||
---
|
||||
@@ -123,8 +138,3 @@ lark-cli base table.records list \
|
||||
- 需要按业务字段名做 upsert 时,优先 `lark-cli base +record-upsert`
|
||||
- 需要配置筛选视图时,优先 `lark-cli base +view-set-filter`
|
||||
- 需要记录历史时,优先 `lark-cli base +record-history-list`
|
||||
|
||||
原生 API 更适合两类场景:
|
||||
|
||||
- 需要逐步核对 `schema base.<resource>.<method>` 的请求参数
|
||||
- 需要精确控制单次表 / 字段 / 记录 / 视图操作
|
||||
|
||||
@@ -43,11 +43,11 @@ This is the foundation of formula logic. You must determine this before writing
|
||||
- Scalars can be used directly in operations: `[Price] * [Quantity]`
|
||||
- Lists cannot be used as scalars — they must be processed first: use `SUM()` for sum, `ARRAYJOIN(",")` for joining, `FIRST()`/`LAST()`/`NTH()` for single value extraction
|
||||
- Link field access `[LinkField].[TargetField]` returns a list (values of the target field for all linked records)
|
||||
- **LISTCOMBINE flattening rule**: When a FILTER's result column is itself a multi-value field (MultiSelect, Link, etc.), it produces a 2D array and **must** be flattened with `.LISTCOMBINE()`; for single-value fields (Number, Text, etc.) it can be omitted, but adding it is never wrong:
|
||||
- **LISTCOMBINE flattening rule**: When a FILTER's result column is itself a multi-value field (`select` with `multiple=true`, `link`, etc.), it produces a 2D array and **must** be flattened with `.LISTCOMBINE()`; for single-value fields (`number`, `text`, etc.) it can be omitted, but adding it is never wrong:
|
||||
|
||||
```
|
||||
[Table].FILTER(CurrentValue.[Field] = [Value]).[MultiSelectCol].LISTCOMBINE() ← required for multi-value columns
|
||||
[Table].FILTER(CurrentValue.[Field] = [Value]).[NumberCol].LISTCOMBINE() ← optional for single-value columns
|
||||
[Table].FILTER(CurrentValue.[Field] = [Value]).[Tags].LISTCOMBINE() ← required for multi-value columns
|
||||
[Table].FILTER(CurrentValue.[Field] = [Value]).[NumberCol].LISTCOMBINE() ← optional for single-value columns
|
||||
```
|
||||
|
||||
---
|
||||
@@ -56,14 +56,14 @@ This is the foundation of formula logic. You must determine this before writing
|
||||
|
||||
### Field storage types
|
||||
|
||||
| Type | Description | Supported operations |
|
||||
| ----------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Number | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation |
|
||||
| Text | Stored as string | String operations; can participate in math if content is numeric, otherwise errors |
|
||||
| Date | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output |
|
||||
| MultiSelect | Data list | List functions, CONTAIN checks |
|
||||
| Link | Links to other table records | Chained access `[LinkField].[Field]`, result is a list |
|
||||
| Boolean | TRUE/FALSE | Logical operations; auto-converts to number when compared with numbers |
|
||||
| Type | Description | Supported operations |
|
||||
|------|-------------|----------------------|
|
||||
| `number` | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation |
|
||||
| `text` | Stored as string | String operations; can participate in math if content is numeric, otherwise errors |
|
||||
| `datetime` | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output |
|
||||
| `select` (`multiple=true`) | Data list | List functions, CONTAIN checks |
|
||||
| `link` | Links to other table records | Chained access `[LinkField].[Field]`, result is a list |
|
||||
| `checkbox` | TRUE/FALSE | Logical operations; auto-converts to number when compared with numbers |
|
||||
|
||||
### Implicit type conversion
|
||||
|
||||
@@ -81,11 +81,11 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
|
||||
|
||||
**Principle**: When types differ, explicitly convert one side rather than relying on implicit conversion:
|
||||
|
||||
- Number vs Text → use `VALUE()` to convert text to number
|
||||
- Date vs Text → use `TEXT()` to convert date to text
|
||||
- Date vs Date equality → Dates include time components, so direct `=` comparison may fail due to different hours/minutes/seconds. For day-level equality, convert to text first: `TEXT([DateA], "YYYY/MM/DD") = TEXT([DateB], "YYYY/MM/DD")`
|
||||
- Select and User fields can be compared with both same-type values and text
|
||||
- Text fields in numeric aggregation (SUM/AVERAGE/MIN/MAX etc.) → convert to number with `VALUE()` first. For FILTER results, use `.MAP(VALUE(CurrentValue)).SUM()`
|
||||
- `number` vs `text` → use `VALUE()` to convert text to number
|
||||
- `datetime` vs `text` → use `TEXT()` to convert date to text
|
||||
- `datetime` vs `datetime` equality → dates include time components, so direct `=` comparison may fail due to different hours/minutes/seconds. For day-level equality, convert to text first: `TEXT([DateA], "YYYY/MM/DD") = TEXT([DateB], "YYYY/MM/DD")`
|
||||
- `select` and `user` fields can be compared with both same-type values and text
|
||||
- `text` fields in numeric aggregation (SUM/AVERAGE/MIN/MAX etc.) → convert to number with `VALUE()` first. For FILTER results, use `.MAP(VALUE(CurrentValue)).SUM()`
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
|
||||
| ---------------------------- | ----------------------- | --------------------------- | --------------------------------------------------------- |
|
||||
| Entire table `[TableName]` | A row in the table | `CurrentValue.[FieldName]` | `[Orders].FILTER(CurrentValue.[Amount] > 100).[Customer]` |
|
||||
| Column `[TableName].[Field]` | A single field value | Use `CurrentValue` directly | `[Orders].[Amount].FILTER(CurrentValue > 100)` |
|
||||
| MultiSelect field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` |
|
||||
| `select` (`multiple=true`) field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` |
|
||||
| LIST-generated list | One element | Use `CurrentValue` directly | `LIST(1,2,3).MAP(CurrentValue * 2)` |
|
||||
|
||||
### Key rules
|
||||
@@ -190,7 +190,7 @@ Retrieves the target field values for all linked records as a list. Supports con
|
||||
```
|
||||
Correct: [Sales].FILTER(CurrentValue.[Amount] > 100).[Customer]
|
||||
Correct: [Sales].FILTER(condition).SORTBY([Sales].[SortCol]).[Customer] ← result column at end of chain
|
||||
Wrong: [Sales].FILTER(CurrentValue.[Amount] > 100) ← missing result column
|
||||
Wrong: [Sales].FILTER(CurrentValue.[Amount] > 100) ← missing result column
|
||||
```
|
||||
|
||||
- **When data range is a column** `[TableName].[Field]` or a list, FILTER returns the filtered list directly — **no** result column needed:
|
||||
@@ -244,9 +244,9 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
|
||||
| ISNULL | `ISNULL(value)` | Boolean | Tests if NULL (only NULL is true; empty string is not) |
|
||||
| ISERROR | `ISERROR(expr)` | Boolean | Tests if expression errors |
|
||||
| ISNUMBER | `ISNUMBER(value)` | Boolean | Tests if value is a number |
|
||||
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains the value; **does NOT do text substring matching** |
|
||||
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains all specified values |
|
||||
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains only the specified values |
|
||||
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains the value; **does NOT do text substring matching** |
|
||||
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains all specified values |
|
||||
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains only the specified values |
|
||||
| TRUE | `TRUE()` | Boolean | Returns TRUE |
|
||||
| FALSE | `FALSE()` | Boolean | Returns FALSE |
|
||||
| RECORD_ID | `RECORD_ID()` | Text | Returns the current row's record ID |
|
||||
@@ -256,7 +256,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
|
||||
### 8.2 Numeric functions
|
||||
|
||||
| Function | Signature | Return type | Description |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| --- | --- | --- | --- |
|
||||
| SUM | `SUM(val1, val2, ...)` | Number | Sum; accepts multiple values or a list |
|
||||
| AVERAGE | `AVERAGE(val1, val2, ...)` | Number | Average |
|
||||
| MAX | `MAX(val1, val2, ...)` | Number | Maximum |
|
||||
@@ -336,7 +336,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
|
||||
### 8.5 List functions
|
||||
|
||||
| Function | Signature | Return type | Description |
|
||||
| ----------- | ---------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| --- | --- | --- | --- |
|
||||
| LIST | `LIST(val1, val2, ...)` | List | Create a list |
|
||||
| FIRST | `FIRST(list)` | Scalar | First element |
|
||||
| LAST | `LAST(list)` | Scalar | Last element |
|
||||
@@ -358,7 +358,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
|
||||
|
||||
| | CONTAIN | CONTAINTEXT |
|
||||
| ----------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Purpose | Tests if **list/MultiSelect** contains a value | Tests if **text** contains a substring |
|
||||
| Purpose | Tests if a **list / `select` (`multiple=true`)** contains a value | Tests if **text** contains a substring |
|
||||
| Example | `[Tags].CONTAIN("Urgent")` | `[Notes].CONTAINTEXT("completed")` |
|
||||
| Wrong usage | `CONTAIN([Notes], "completed")` — cannot do substring matching | `CONTAINTEXT([Tags], "Urgent")` — Tags is a list, not text |
|
||||
|
||||
@@ -494,7 +494,7 @@ DAYS([EndDate], [StartDate])
|
||||
### Pattern 7: List element mapping
|
||||
|
||||
```
|
||||
[MultiSelectField].MAP(CurrentValue & " tag")
|
||||
[SelectField(which multiple=true)].MAP(CurrentValue & " tag")
|
||||
SPLIT([TextField], ",").MAP(TRIM(CurrentValue))
|
||||
```
|
||||
|
||||
@@ -579,13 +579,13 @@ Wrong: CONTAIN([Notes], "urgent")
|
||||
Correct: CONTAINTEXT([Notes], "urgent")
|
||||
```
|
||||
|
||||
Reason: CONTAIN checks if a list/MultiSelect contains a whole value, not substring matching. Use CONTAINTEXT for text substrings.
|
||||
Reason: CONTAIN checks if a list or `select` (`multiple=true`) contains a whole value, not substring matching. Use CONTAINTEXT for text substrings.
|
||||
|
||||
### Mistake 9: Date concatenation without formatting
|
||||
|
||||
```
|
||||
Not recommended: "Deadline: " & [DateField] ← output format is uncontrolled
|
||||
Recommended: "Deadline: " & TEXT([DateField], "YYYY-MM-DD")
|
||||
Not recommended: "Deadline: " & [DateField] ← output format is uncontrolled
|
||||
Recommended: "Deadline: " & TEXT([DateField], "YYYY-MM-DD")
|
||||
```
|
||||
|
||||
Reason: Concatenating a date with `&` won't error, but uses the default format. Use TEXT to specify the format explicitly.
|
||||
@@ -649,9 +649,9 @@ IF(
|
||||
|
||||
**Table structure**:
|
||||
|
||||
- Orders: ID (AutoNumber), OrderItems (Link [target: OrderItems, foreign key: ID])
|
||||
- OrderItems: ID (AutoNumber), Product (Link [target: Products, foreign key: ID])
|
||||
- Products: ID (AutoNumber), ProductName (Text)
|
||||
- Orders: ID (`auto_number`), OrderItems (`link` [target: OrderItems, foreign key: ID])
|
||||
- OrderItems: ID (`auto_number`), Product (`link` [target: Products, foreign key: ID])
|
||||
- Products: ID (`auto_number`), ProductName (`text`)
|
||||
|
||||
**Current table**: Orders
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ POST /open-apis/base/v3/bases/:base_token/copy
|
||||
- CLI 会额外标记 `copied: true`。
|
||||
- 回复结果时,必须主动返回新 Base 的可访问链接:
|
||||
- 优先使用返回结果中的 `base.url`
|
||||
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token` 或 `app_token`
|
||||
- 同时返回新 Base 的 token
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
|
||||
@@ -38,7 +38,7 @@ POST /open-apis/base/v3/bases
|
||||
- CLI 会额外标记 `created: true`。
|
||||
- 回复结果时,必须主动返回新 Base 的可访问链接:
|
||||
- 优先使用返回结果中的 `base.url`
|
||||
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token` 或 `app_token`
|
||||
- 同时返回新 Base 的 token
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
|
||||
@@ -72,15 +72,18 @@
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 user
|
||||
### 2.6 user / group_chat
|
||||
|
||||
用对象数组,元素至少包含 `id`。单选/多选人员字段都使用数组;`id` 必须是可被当前 Base 识别的用户 ID,写入前确认字段是否允许多选人员。
|
||||
用对象数组,元素至少包含 `id`。人员字段传用户 ID(如 `ou_xxx`),群字段传群 ID(如 `oc_xxx`);单值/多值都统一使用数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"负责人": [
|
||||
{ "id": "ou_xxx" },
|
||||
{ "id": "ou_xxx2" }
|
||||
],
|
||||
"协作群": [
|
||||
{ "id": "oc_xxx" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user