Compare commits

...

13 Commits

Author SHA1 Message Date
zhoujunteng
b573a3fe01 docs: document upload report helpers 2026-07-29 16:59:05 +08:00
zhoujunteng
7846b89754 test(drive): skip import workflow without tenant token 2026-07-29 15:39:58 +08:00
zhoujunteng
89138261fd feat: report upload file events 2026-07-29 15:22:33 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
37 changed files with 2269 additions and 185 deletions

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
@@ -1685,6 +1701,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.78",
"version": "1.0.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.78",
"version": "1.0.79",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.78",
"version": "1.0.79",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -157,7 +157,7 @@ func localImagePath(src string) string {
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.larkoffice.com"
host := "internal-api-drive-stream.feishu.cn"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}

View File

@@ -68,7 +68,7 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "larkoffice"},
{core.BrandFeishu, "feishu.cn"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)

View File

@@ -288,3 +288,18 @@ func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) {
t.Errorf("LogID = %q, want lz", p.LogID)
}
}
func TestRuntimeContextMarkFileEventReported(t *testing.T) {
rt := &RuntimeContext{}
if !rt.MarkFileEventReported() {
t.Fatal("first mark should report")
}
if rt.MarkFileEventReported() {
t.Fatal("second mark should be skipped")
}
var nilRT *RuntimeContext
if nilRT.MarkFileEventReported() {
t.Fatal("nil receiver should not report")
}
}

View File

@@ -23,6 +23,13 @@ const (
driveMediaUploadFinishAction = "upload media finish failed"
)
const (
driveMediaUploadAllPath = "/open-apis/drive/v1/medias/upload_all"
driveMediaUploadPreparePath = "/open-apis/drive/v1/medias/upload_prepare"
driveMediaUploadPartPath = "/open-apis/drive/v1/medias/upload_part"
driveMediaUploadFinishPath = "/open-apis/drive/v1/medias/upload_finish"
)
type DriveMediaMultipartUploadSession struct {
UploadID string
BlockSize int64
@@ -83,20 +90,33 @@ func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllCo
}
fd.AddFile("file", fileReader)
meta := LarkCLIFileEventMeta{
APIPath: driveMediaUploadAllPath,
UploadMode: "singlepart",
ResourceType: "media",
ParentType: cfg.ParentType,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
ApiPath: driveMediaUploadAllPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
return "", ReportUploadFileEventOnError(runtime, prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction), meta)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
return "", ReportUploadFileEventOnError(runtime, prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction), meta)
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
fileToken, err := extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
if err != nil {
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
meta.FileToken = fileToken
ReportUploadFileEvent(runtime, meta)
return fileToken, nil
}
// UploadDriveMediaMultipartTyped uploads a file in server-planned chunks:
@@ -118,22 +138,37 @@ func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMulti
prepareBody["extra"] = cfg.Extra
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
meta := LarkCLIFileEventMeta{
APIPath: driveMediaUploadPreparePath,
UploadMode: "multipart",
ResourceType: "media",
ParentType: cfg.ParentType,
}
data, err := runtime.CallAPITyped("POST", driveMediaUploadPreparePath, nil, prepareBody)
if err != nil {
return "", err
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
if err != nil {
return "", err
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
meta.APIPath = driveMediaUploadPartPath
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", err
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
meta.APIPath = driveMediaUploadFinishPath
fileToken, err := finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
if err != nil {
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
meta.FileToken = fileToken
ReportUploadFileEvent(runtime, meta)
return fileToken, nil
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
@@ -235,7 +270,7 @@ func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
ApiPath: driveMediaUploadPartPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
@@ -249,7 +284,7 @@ func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string
}
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", driveMediaUploadFinishPath, nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})

View File

@@ -304,3 +304,274 @@ func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
t.Fatalf("message = %q", p.Message)
}
}
// registerDriveMediaReportStub registers a successful report_file_event stub.
func registerDriveMediaReportStub(t *testing.T, reg *httpmock.Registry) *httpmock.Stub {
t.Helper()
return registerDriveMediaReportStubWithMsg(t, reg, "")
}
// registerDriveMediaReportStubWithMsg registers a report_file_event stub that
// returns code 0 and, when msg is non-empty, carries it as the top-level msg
// (the capacity-expansion URL for tenant-capacity-exceeded uploads).
func registerDriveMediaReportStubWithMsg(t *testing.T, reg *httpmock.Registry, msg string) *httpmock.Stub {
t.Helper()
body := map[string]interface{}{"code": 0, "data": map[string]interface{}{}}
if msg != "" {
body["msg"] = msg
}
stub := &httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: body,
Reusable: true,
}
reg.Register(stub)
return stub
}
// assertSingleReport verifies one upload report with the expected status and
// returns its decoded tags for additional assertions.
func assertSingleReport(t *testing.T, reportStub *httpmock.Stub, wantStatus string) map[string]interface{} {
t.Helper()
if len(reportStub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(reportStub.CapturedBodies))
}
body := decodeCapturedDriveMediaJSONBody(t, reportStub)
assertReportEnvelope(t, body)
if _, ok := body["user_id"]; ok {
t.Fatalf("user_id must be omitted, got %v", body["user_id"])
}
if _, ok := body["tenant_id"]; ok {
t.Fatalf("tenant_id must be omitted, got %v", body["tenant_id"])
}
tags := assertTagsObject(t, body)
if got := tags["status"]; got != wantStatus {
t.Fatalf("tags.status = %v, want %s", got, wantStatus)
}
return tags
}
func TestUploadDriveMediaAllTypedReportsFileEventOnSuccess(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStub(t, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_ok"},
},
})
payload := []byte{0x89, 0x50}
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err != nil {
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
}
if fileToken != "file_ok" {
t.Fatalf("fileToken = %q, want file_ok", fileToken)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusSuccess)
if got := tags["api_path"]; got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("tags.api_path = %v", got)
}
if got := tags["upload_mode"]; got != "singlepart" {
t.Fatalf("tags.upload_mode = %v, want singlepart", got)
}
if got := tags["resource_type"]; got != "media" {
t.Fatalf("tags.resource_type = %v, want media", got)
}
if got := tags["mount_point"]; got != "docx_image" {
t.Fatalf("tags.mount_point = %v, want docx_image", got)
}
if got := tags["file_token"]; got != "file_ok" {
t.Fatalf("tags.file_token = %v, want file_ok", got)
}
}
func TestUploadDriveMediaAllTypedReportsFileEventOnError(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStub(t, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 999, "msg": "upload rejected"},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 999 {
t.Fatalf("expected typed api error code 999, got %T (%v)", err, err)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusError)
if got := tags["code"]; got != "999" {
t.Fatalf("tags.code = %v, want 999", got)
}
}
func TestUploadDriveMediaAllTypedReportFailureKeepsUploadError(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: map[string]interface{}{"code": 500, "msg": "report rejected"},
Reusable: true,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1061101 {
t.Fatalf("code = %d, want original 1061101", p.Code)
}
// The report failed (code 500), so no capacity-expansion URL is available.
// Keep the quota hint produced by API error classification unchanged.
const wantHint = "reduce the request volume or free quota, then retry after the relevant quota resets"
if p.Hint != wantHint {
t.Fatalf("hint = %q, want original classified hint %q", p.Hint, wantHint)
}
}
func TestUploadDriveMediaMultipartTypedReportsFileEventOnPrepareError(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStubWithMsg(t, reg, testCapacityExpansionURL)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 1061101 {
t.Fatalf("expected typed api error code 1061101, got %T (%v)", err, err)
}
if !strings.Contains(p.Hint, testCapacityExpansionURL) {
t.Fatalf("hint = %q, want capacity expansion URL", p.Hint)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusError)
if got := tags["upload_mode"]; got != "multipart" {
t.Fatalf("tags.upload_mode = %v, want multipart", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("tags.api_path = %v, want upload_prepare", got)
}
if got := tags["code"]; got != "1061101" {
t.Fatalf("tags.code = %v, want 1061101", got)
}
}
func TestUploadDriveMediaMultipartTypedReportsFileEventOnSuccess(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStub(t, reg)
size := MaxDriveMediaUploadSinglePartSize + 1
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_ok",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_multi_ok"},
},
})
payload := bytes.Repeat([]byte{0xCD}, int(size))
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "",
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
}
if fileToken != "file_multi_ok" {
t.Fatalf("fileToken = %q, want file_multi_ok", fileToken)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusSuccess)
if got := tags["upload_mode"]; got != "multipart" {
t.Fatalf("tags.upload_mode = %v, want multipart", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("tags.api_path = %v, want upload_finish", got)
}
if got := tags["file_token"]; got != "file_multi_ok" {
t.Fatalf("tags.file_token = %v, want file_multi_ok", got)
}
}

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
)
const (
larkCLIReportFileEventPath = "/open-apis/drive/v1/lark_cli_file_event/report"
uploadFileEventReportTimeout = 3 * time.Second
uploadFileEventStatusSuccess = "success"
uploadFileEventStatusError = "error"
)
// LarkCLIFileEventMeta describes the upload context attached to a best-effort
// report_file_event call. Identity (user_id / tenant_id) is intentionally
// omitted: the server derives it from the authenticated request context.
type LarkCLIFileEventMeta struct {
APIPath string
Command string
UploadMode string
ResourceType string
Status string
Code string
// ParentType is the upload request's parent_type (explorer / wiki /
// docx_file / sheet_image / slide_file / email / bitable_file /
// ccm_import_open ...). It is reported verbatim as the tags mount_point.
ParentType string
// FileToken is the uploaded file's token, set only on success paths and
// reported as the tags file_token. Empty on failure paths.
FileToken string
}
// IsTenantCapacityExceeded reports whether err is a typed API error carrying a
// tenant-capacity-exceeded code recognized by the CLI upload reporting flow.
// The code set mirrors the storage service source of truth.
func IsTenantCapacityExceeded(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok || p == nil {
return false
}
switch p.Code {
case 1061101:
return true
default:
return false
}
}
// ReportUploadFileEvent best-effort reports a successful upload file event once
// per RuntimeContext. The report call's failure is swallowed; it never affects
// the caller's success path.
func ReportUploadFileEvent(runtime *RuntimeContext, meta LarkCLIFileEventMeta) {
if runtime == nil {
return
}
if strings.TrimSpace(meta.Status) == "" {
meta.Status = uploadFileEventStatusSuccess
}
if !runtime.MarkFileEventReported() {
return
}
_ = postUploadFileEvent(runtime, meta)
}
// ReportUploadFileEventOnError best-effort reports a failed upload once per
// RuntimeContext, then returns the original uploadErr. The report call's own
// failure never replaces uploadErr. When uploadErr is a tenant-capacity-exceeded
// error, the capacity-expansion URL carried by the report response's msg is
// appended to its .hint (only when the report returns a non-empty msg), without
// altering type / subtype / code / message.
func ReportUploadFileEventOnError(runtime *RuntimeContext, uploadErr error, meta LarkCLIFileEventMeta) error {
if uploadErr == nil {
return nil
}
if strings.TrimSpace(meta.Status) == "" {
meta.Status = uploadFileEventStatusError
}
if strings.TrimSpace(meta.Code) == "" {
if p, ok := errs.ProblemOf(uploadErr); ok && p != nil && p.Code != 0 {
meta.Code = strconv.Itoa(p.Code)
}
}
var reportMsg string
if runtime != nil && runtime.MarkFileEventReported() {
reportMsg = postUploadFileEvent(runtime, meta)
}
return appendTenantCapacityHint(uploadErr, reportMsg)
}
// postUploadFileEvent sends the best-effort report and returns the report
// response's capacity-expansion URL. The server currently carries this URL in
// data.msg; some responses also include a generic top-level msg like "success",
// which must not be mistaken for a URL. Any transport / parse failure or a
// non-zero response code yields an empty string, and the report never affects
// the caller's flow.
func postUploadFileEvent(runtime *RuntimeContext, meta LarkCLIFileEventMeta) string {
return postUploadFileEventWithTimeout(runtime, meta, uploadFileEventReportTimeout)
}
// postUploadFileEventWithTimeout sends the report within the supplied timeout
// and returns a validated capacity-expansion URL from a successful response.
func postUploadFileEventWithTimeout(runtime *RuntimeContext, meta LarkCLIFileEventMeta, timeout time.Duration) string {
reportCtx, cancel := context.WithTimeout(runtime.Ctx(), timeout)
defer cancel()
resp, err := runtime.DoAPIWithContext(reportCtx, &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: larkCLIReportFileEventPath,
Body: buildUploadReportRequest(runtime, meta),
})
if err != nil || resp == nil {
return ""
}
parsed, err := client.ParseJSONResponse(resp)
if err != nil {
return ""
}
envelope, ok := parsed.(map[string]interface{})
if !ok {
return ""
}
if GetFloat(envelope, "code") != 0 {
return ""
}
return extractCapacityExpansionURL(envelope)
}
// extractCapacityExpansionURL returns the first valid capacity-expansion URL
// carried by the report response, preferring data.msg over the top-level msg.
func extractCapacityExpansionURL(envelope map[string]interface{}) string {
for _, candidate := range []string{
GetString(envelope, "data", "msg"),
GetString(envelope, "msg"),
} {
if u := sanitizeCapacityExpansionURL(candidate); u != "" {
return u
}
}
return ""
}
// sanitizeCapacityExpansionURL accepts absolute HTTP(S) URLs and rejects empty,
// relative, or malformed report response values.
func sanitizeCapacityExpansionURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
if (u.Scheme != "http" && u.Scheme != "https") ||
strings.TrimSpace(u.Host) == "" ||
strings.TrimSpace(u.Hostname()) == "" ||
strings.HasSuffix(u.Host, ":") ||
strings.HasPrefix(u.Path, "//") {
return ""
}
return u.String()
}
// AppendUploadFileEventDryRun describes the success-path report request that
// follows an upload. Error-path reporting uses the same envelope with status
// and code populated from the typed upload error at runtime.
func AppendUploadFileEventDryRun(dry *DryRunAPI, runtime *RuntimeContext, meta LarkCLIFileEventMeta) {
if dry == nil {
return
}
if strings.TrimSpace(meta.Status) == "" {
meta.Status = uploadFileEventStatusSuccess
}
dry.POST(larkCLIReportFileEventPath).
Desc("Best-effort report of the completed upload").
Body(buildUploadReportRequest(runtime, meta))
}
// buildUploadReportRequest assembles the minimal report body: fixed event
// fields plus tags. Identity fields are never included.
func buildUploadReportRequest(runtime *RuntimeContext, meta LarkCLIFileEventMeta) map[string]interface{} {
command := strings.TrimSpace(meta.Command)
if command == "" {
command = commandPathOrName(runtime)
}
tags := map[string]string{
"code": strings.TrimSpace(meta.Code),
"api_path": strings.TrimSpace(meta.APIPath),
"command": command,
"upload_mode": strings.TrimSpace(meta.UploadMode),
"resource_type": strings.TrimSpace(meta.ResourceType),
"status": strings.TrimSpace(meta.Status),
"mount_point": strings.TrimSpace(meta.ParentType),
"file_token": strings.TrimSpace(meta.FileToken),
}
return map[string]interface{}{
"file_scene": "lark-cli",
"scene": "upload",
"operation": "upload",
"tags": tags,
}
}
// appendTenantCapacityHint adds the capacity-expansion URL (carried by the
// report response's msg) to a tenant-capacity-exceeded error's hint, preserving
// any existing hint and never touching type / subtype / code / message. It is a
// no-op for non-quota errors and when the report returned no URL.
func appendTenantCapacityHint(err error, reportMsg string) error {
if !IsTenantCapacityExceeded(err) {
return err
}
url := strings.TrimSpace(reportMsg)
if url == "" {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || p == nil {
return err
}
hint := "tenant storage capacity is exceeded. Open this URL to expand capacity: " + url
switch {
case strings.TrimSpace(p.Hint) == "":
p.Hint = hint
case strings.Contains(p.Hint, url):
// already present; do not duplicate
default:
p.Hint = p.Hint + "\n" + hint
}
return err
}
// commandPathOrName returns the best-effort command identifier for upload
// reporting, preferring the full command path and falling back to the shortcut
// name. Empty is allowed for low-level helpers used outside a mounted shortcut.
func commandPathOrName(runtime *RuntimeContext) string {
if runtime == nil {
return ""
}
if runtime.Cmd != nil {
path := strings.TrimSpace(runtime.Cmd.CommandPath())
path = strings.TrimPrefix(path, "lark-cli ")
path = strings.TrimPrefix(path, "lark ")
if path != "" {
return path
}
}
return runtime.Command()
}

View File

@@ -0,0 +1,369 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"net/http"
"strings"
"testing"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
// newUploadFileEventRuntime creates an isolated runtime and HTTP stub registry
// for upload file-event reporting tests.
func newUploadFileEventRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+upload"}, cfg, f, core.AsUser)
return rt, reg
}
// testCapacityExpansionURL is a placeholder capacity-expansion URL used in
// tests. It intentionally uses example.com so no internal endpoint is embedded
// in the repository.
const testCapacityExpansionURL = "https://example.com/space/upload/pay/prepare"
// registerReportStub registers a report_file_event response with no message.
func registerReportStub(t *testing.T, reg *httpmock.Registry, code int) *httpmock.Stub {
t.Helper()
return registerReportStubWithMsg(t, reg, code, "")
}
// registerReportStubWithMsg registers a report_file_event stub returning the
// given top-level code and msg.
func registerReportStubWithMsg(t *testing.T, reg *httpmock.Registry, code int, msg string) *httpmock.Stub {
t.Helper()
return registerReportStubWithBody(t, reg, map[string]interface{}{
"code": code,
"data": map[string]interface{}{},
"msg": msg,
})
}
// registerReportStubWithBody registers the supplied report_file_event response.
func registerReportStubWithBody(t *testing.T, reg *httpmock.Registry, body map[string]interface{}) *httpmock.Stub {
t.Helper()
stub := &httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: body,
Reusable: true,
}
reg.Register(stub)
return stub
}
func TestIsTenantCapacityExceeded(t *testing.T) {
if !IsTenantCapacityExceeded(errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)) {
t.Fatal("code 1061101 should be recognized as tenant capacity exceeded")
}
// Legacy quota codes are intentionally no longer recognized: only the
// tenant-capacity-exceeded code 1061101 gates the expansion hint.
for _, code := range []int{11001, 90008072, 90003081, 10690008072, 10690003081} {
err := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(code)
if IsTenantCapacityExceeded(err) {
t.Fatalf("code %d must not be recognized as tenant capacity exceeded", code)
}
}
if IsTenantCapacityExceeded(errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(12345)) {
t.Fatal("unexpected recognition for unrelated quota code")
}
if IsTenantCapacityExceeded(errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input")) {
t.Fatal("non api error must not be recognized")
}
}
func TestReportUploadFileEvent_Success_ReportsOnceWithMinimalBody(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
reportStub := registerReportStub(t, reg, 0)
meta := LarkCLIFileEventMeta{
APIPath: "/open-apis/drive/v1/medias/upload_all",
Command: "drive +upload",
UploadMode: "singlepart",
ResourceType: "media",
ParentType: "docx_file",
FileToken: "boxcnabc123",
}
ReportUploadFileEvent(runtime, meta)
ReportUploadFileEvent(runtime, meta)
if len(reportStub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(reportStub.CapturedBodies))
}
body := decodeCapturedDriveMediaJSONBody(t, reportStub)
assertReportEnvelope(t, body)
if _, ok := body["user_id"]; ok {
t.Fatalf("user_id must be omitted, got %v", body["user_id"])
}
if _, ok := body["tenant_id"]; ok {
t.Fatalf("tenant_id must be omitted, got %v", body["tenant_id"])
}
tags := assertTagsObject(t, body)
if got := tags["status"]; got != uploadFileEventStatusSuccess {
t.Fatalf("tags.status = %v, want success", got)
}
if got := tags["api_path"]; got != meta.APIPath {
t.Fatalf("tags.api_path = %v, want %s", got, meta.APIPath)
}
if got := tags["command"]; got != meta.Command {
t.Fatalf("tags.command = %v, want %s", got, meta.Command)
}
if got := tags["upload_mode"]; got != meta.UploadMode {
t.Fatalf("tags.upload_mode = %v, want %s", got, meta.UploadMode)
}
if got := tags["resource_type"]; got != meta.ResourceType {
t.Fatalf("tags.resource_type = %v, want %s", got, meta.ResourceType)
}
if got := tags["mount_point"]; got != meta.ParentType {
t.Fatalf("tags.mount_point = %v, want %s", got, meta.ParentType)
}
if got := tags["file_token"]; got != meta.FileToken {
t.Fatalf("tags.file_token = %v, want %s", got, meta.FileToken)
}
}
func TestBuildUploadReportRequest_CommandOmitsBinaryName(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
drive := &cobra.Command{Use: "drive"}
upload := &cobra.Command{Use: "+upload"}
root.AddCommand(drive)
drive.AddCommand(upload)
body := buildUploadReportRequest(&RuntimeContext{Cmd: upload}, LarkCLIFileEventMeta{})
tags := assertTagsObject(t, body)
if got := tags["command"]; got != "drive +upload" {
t.Fatalf("tags.command = %v, want drive +upload", got)
}
}
func TestReportUploadFileEventOnError_ReportsAndPreservesError(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
reportStub := registerReportStub(t, reg, 0)
uploadErr := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(42)
meta := LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_all"}
returned := ReportUploadFileEventOnError(runtime, uploadErr, meta)
if returned != uploadErr {
t.Fatalf("returned error changed: got %v want original %v", returned, uploadErr)
}
returned = ReportUploadFileEventOnError(runtime, uploadErr, meta)
if returned != uploadErr {
t.Fatalf("second call changed error: got %v want original %v", returned, uploadErr)
}
if len(reportStub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(reportStub.CapturedBodies))
}
tags := assertTagsObject(t, decodeCapturedDriveMediaJSONBody(t, reportStub))
if got := tags["status"]; got != uploadFileEventStatusError {
t.Fatalf("tags.status = %v, want error", got)
}
if got := tags["code"]; got != "42" {
t.Fatalf("tags.code = %v, want 42", got)
}
}
func TestReportUploadFileEventOnError_ReportFailureDoesNotReplaceUploadError(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: map[string]interface{}{"code": 999, "msg": "report rejected"},
})
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(10690008072)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
if returned != uploadErr {
t.Fatalf("returned error changed: got %v want original %v", returned, uploadErr)
}
}
func TestReportUploadFileEventOnError_AppendsCapacityExpansionHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithBody(t, reg, map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"msg": testCapacityExpansionURL,
},
})
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if !strings.Contains(p.Hint, testCapacityExpansionURL) {
t.Fatalf("hint = %q, want it to contain %q", p.Hint, testCapacityExpansionURL)
}
if p.Code != 1061101 {
t.Fatalf("code changed: got %d, want 1061101", p.Code)
}
if p.Subtype != errs.SubtypeQuotaExceeded {
t.Fatalf("subtype changed: got %q, want %q", p.Subtype, errs.SubtypeQuotaExceeded)
}
}
func TestReportUploadFileEventOnError_TopLevelSuccessMsgDoesNotBecomeHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithMsg(t, reg, 0, "success")
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.TrimSpace(p.Hint) != "" {
t.Fatalf("top-level success msg must not become hint, got %q", p.Hint)
}
}
func TestReportUploadFileEventOnError_InvalidURLInDataMsgIsIgnored(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithBody(t, reg, map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"msg": "https://https://example.com/space/upload/pay/prepare",
},
})
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.TrimSpace(p.Hint) != "" {
t.Fatalf("invalid data.msg URL must be ignored, got %q", p.Hint)
}
}
func TestReportUploadFileEventOnError_EmptyReportMsgYieldsNoHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
// report returns code 0 but no msg: no capacity-expansion URL to surface.
registerReportStub(t, reg, 0)
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.TrimSpace(p.Hint) != "" {
t.Fatalf("empty report msg must yield no hint, got %q", p.Hint)
}
if p.Code != 1061101 {
t.Fatalf("code changed: got %d, want 1061101", p.Code)
}
}
func TestReportUploadFileEventOnError_NonQuotaErrorKeepsHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithMsg(t, reg, 0, testCapacityExpansionURL)
uploadErr := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(42)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.Contains(p.Hint, testCapacityExpansionURL) {
t.Fatalf("non-quota error must not get expansion hint, got %q", p.Hint)
}
}
func TestReportUploadFileEventOnError_NilErrorIsNoop(t *testing.T) {
runtime, _ := newUploadFileEventRuntime(t)
// No report stub is registered: a nil upload error must not attempt a
// report at all (an unexpected POST would fail with "no stub").
if err := ReportUploadFileEventOnError(runtime, nil, LarkCLIFileEventMeta{}); err != nil {
t.Fatalf("nil upload error should return nil, got %v", err)
}
// The reporting mark must remain unconsumed, proving no report fired.
if !runtime.MarkFileEventReported() {
t.Fatal("nil error path must not consume the file-event report mark")
}
}
type contextBlockingRoundTripper struct{}
// RoundTrip blocks until the request context expires, allowing timeout behavior
// to be tested without performing a network request.
func (contextBlockingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
}
func TestPostUploadFileEventWithTimeout_BoundsBestEffortRequest(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
f.LarkClient = func() (*lark.Client, error) {
return lark.NewClient("cli_x", "test-secret", lark.WithHttpClient(&http.Client{
Transport: contextBlockingRoundTripper{},
})), nil
}
runtime := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+upload"}, cfg, f, core.AsUser)
started := time.Now()
if got := postUploadFileEventWithTimeout(runtime, LarkCLIFileEventMeta{}, 10*time.Millisecond); got != "" {
t.Fatalf("postUploadFileEventWithTimeout() = %q, want empty result on timeout", got)
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("best-effort report took %s, want it bounded by the request context", elapsed)
}
}
// assertReportEnvelope verifies the fixed fields in an upload report body.
func assertReportEnvelope(t *testing.T, body map[string]interface{}) {
t.Helper()
if got := body["file_scene"]; got != "lark-cli" {
t.Fatalf("file_scene = %v, want lark-cli", got)
}
if got := body["scene"]; got != "upload" {
t.Fatalf("scene = %v, want upload", got)
}
if got := body["operation"]; got != "upload" {
t.Fatalf("operation = %v, want upload", got)
}
}
// assertTagsObject returns the report tags as a generic JSON-style object.
func assertTagsObject(t *testing.T, body map[string]interface{}) map[string]interface{} {
t.Helper()
switch tags := body["tags"].(type) {
case map[string]interface{}:
return tags
case map[string]string:
result := make(map[string]interface{}, len(tags))
for key, value := range tags {
result[key] = value
}
return result
default:
t.Fatalf("tags = %#v, want object", body["tags"])
return nil
}
}

View File

@@ -40,16 +40,19 @@ type RuntimeContext struct {
Config *core.CliConfig
Cmd *cobra.Command
Format string
JqExpr string // --jq expression; empty = no filter
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
outputErr error // deferred error from jq filtering; written at most once
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
JqExpr string // --jq expression; empty = no filter
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
outputErr error // deferred error from jq filtering; written at most once
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
// fileEventReportOnce guards best-effort upload file-event reporting so it is
// emitted at most once per command run (see MarkFileEventReported).
fileEventReportOnce sync.Once
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
}
// ── Identity ──
@@ -75,6 +78,20 @@ func (ctx *RuntimeContext) IsBot() bool {
return ctx.As().IsBot()
}
// MarkFileEventReported returns true only on the first successful mark within
// this RuntimeContext. Upload file-event reporting is best-effort and should
// happen at most once per command execution.
func (ctx *RuntimeContext) MarkFileEventReported() bool {
if ctx == nil {
return false
}
report := false
ctx.fileEventReportOnce.Do(func() {
report = true
})
return report
}
// Command returns the shortcut command name as cobra knows it (e.g.
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
// validation) that key off the shortcut identity.
@@ -450,14 +467,24 @@ func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interfa
// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating
// the identity → token logic across the generic and shortcut API paths.
func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
return ctx.DoAPIWithContext(ctx.ctx, req, opts...)
}
// DoAPIWithContext executes a raw Lark SDK request with an explicit context.
// Callers that perform best-effort or otherwise bounded side requests can use
// this without changing the RuntimeContext's command-wide context.
func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
if callCtx == nil {
callCtx = ctx.ctx
}
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
if optFn := cmdutil.ShortcutHeaderOpts(callCtx); optFn != nil {
opts = append(opts, optFn)
}
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
return ac.DoSDKRequest(callCtx, req, ctx.As(), opts...)
}
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),

View File

@@ -105,6 +105,7 @@ func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.Dr
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
appendDriveImportUploadReportDryRun(dry, runtime, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
@@ -259,6 +260,24 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec,
})
}
// appendDriveImportUploadReportDryRun adds the best-effort upload report to an
// import dry-run plan, matching the single-part or multipart upload path.
func appendDriveImportUploadReportDryRun(dry *common.DryRunAPI, runtime *common.RuntimeContext, fileSize int64) {
apiPath := "/open-apis/drive/v1/medias/upload_all"
uploadMode := "singlepart"
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
apiPath = "/open-apis/drive/v1/medias/upload_finish"
uploadMode = "multipart"
}
common.AppendUploadFileEventDryRun(dry, runtime, common.LarkCLIFileEventMeta{
APIPath: apiPath,
UploadMode: uploadMode,
ResourceType: "media",
ParentType: "ccm_import_open",
FileToken: "<file_token from upload response>",
})
}
// normalizeDriveImportKindForURL maps the server's import "type" field to a
// canonical kind BuildResourceURL recognizes. status.DocType comes straight
// from the API and isn't normalized; if it ever returns aliases like "sheets"

View File

@@ -109,14 +109,15 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
var got struct {
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
if len(got.API) != 5 {
t.Fatalf("expected 5 API calls, got %d", len(got.API))
}
wantDesc := "After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it."
if got.API[len(got.API)-1].Desc != wantDesc {
@@ -132,7 +133,11 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
}
importName, _ := got.API[2].Body["file_name"].(string)
if got.API[2].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[2].URL)
}
importName, _ := got.API[3].Body["file_name"].(string)
if importName != "base-import" {
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
}
@@ -186,8 +191,8 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 5 {
t.Fatalf("expected 5 API calls, got %d", len(got.API))
if len(got.API) != 6 {
t.Fatalf("expected 6 API calls, got %d", len(got.API))
}
if got.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("dry-run first URL = %q, want upload_prepare", got.API[0].URL)
@@ -198,6 +203,9 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
if got.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("dry-run third URL = %q, want upload_finish", got.API[2].URL)
}
if got.API[3].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[3].URL)
}
}
func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
@@ -475,12 +483,16 @@ func TestDriveImportDryRunWithTargetToken(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
}
// The import task body (API[1]) should contain target_token in point
importTaskBody := got.API[1].Body
if got.API[1].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[1].URL)
}
// The import task body (API[2]) should contain target_token in point.
importTaskBody := got.API[2].Body
point, ok := importTaskBody["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", importTaskBody["point"])

View File

@@ -1109,8 +1109,8 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
if got.API[0].Body["parent_type"] != driveUploadParentTypeWiki {
t.Fatalf("parent_type = %#v, want %q", got.API[0].Body["parent_type"], driveUploadParentTypeWiki)
@@ -1118,11 +1118,14 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if got.API[0].Body["parent_node"] != "wikcn_dryrun_upload_target" {
t.Fatalf("parent_node = %#v, want %q", got.API[0].Body["parent_node"], "wikcn_dryrun_upload_target")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
if got.API[1].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
if got.API[2].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[2].URL)
}
if got.API[2].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[2].Body["with_url"])
}
wantPostUploadNote := "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file."
if got.PostUploadNote != wantPostUploadNote {
@@ -1210,17 +1213,20 @@ func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
if got.API[1].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
if got.API[2].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[2].URL)
}
if got.API[2].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[2].Body["with_url"])
}
}
@@ -1264,8 +1270,8 @@ func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
@@ -1596,3 +1602,254 @@ func decodeDriveMultipartBody(t *testing.T, stub *httpmock.Stub) capturedDriveMu
}
return body
}
const driveReportFileEventPath = "/open-apis/drive/v1/lark_cli_file_event/report"
// testDriveCapacityExpansionURL is a placeholder capacity-expansion URL used in
// tests. It intentionally uses example.com so no internal endpoint is embedded
// in the repository.
const testDriveCapacityExpansionURL = "https://example.com/space/upload/pay/prepare"
// registerDriveReportStub registers a successful report_file_event stub.
func registerDriveReportStub(t *testing.T, reg *httpmock.Registry) *httpmock.Stub {
t.Helper()
return registerDriveReportStubWithMsg(t, reg, "")
}
// registerDriveReportStubWithMsg registers a report_file_event stub returning
// code 0 and, when msg is non-empty, carrying it as data.msg.
func registerDriveReportStubWithMsg(t *testing.T, reg *httpmock.Registry, msg string) *httpmock.Stub {
t.Helper()
body := map[string]interface{}{"code": 0, "data": map[string]interface{}{}}
if msg != "" {
body["msg"] = "success"
body["data"] = map[string]interface{}{"msg": msg}
}
stub := &httpmock.Stub{
Method: "POST",
URL: driveReportFileEventPath,
Body: body,
Reusable: true,
}
reg.Register(stub)
return stub
}
// decodeDriveReportTags verifies one captured Drive report and returns its tags.
func decodeDriveReportTags(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
if len(stub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(stub.CapturedBodies))
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBodies[0], &body); err != nil {
t.Fatalf("decode report body: %v", err)
}
if got := body["file_scene"]; got != "lark-cli" {
t.Fatalf("file_scene = %v, want lark-cli", got)
}
if got := body["scene"]; got != "upload" {
t.Fatalf("scene = %v, want upload", got)
}
if _, ok := body["user_id"]; ok {
t.Fatalf("user_id must be omitted, got %v", body["user_id"])
}
if _, ok := body["tenant_id"]; ok {
t.Fatalf("tenant_id must be omitted, got %v", body["tenant_id"])
}
tags, ok := body["tags"].(map[string]interface{})
if !ok {
t.Fatalf("tags = %#v, want object", body["tags"])
}
return tags
}
func TestDriveUploadSmallFileReportFileEventOnSuccess(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-small-ok", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reportStub := registerDriveReportStub(t, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_report_ok"},
},
})
withDriveWorkingDir(t, t.TempDir())
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "small.bin", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected upload to succeed, got error: %v", err)
}
tags := decodeDriveReportTags(t, reportStub)
if got := tags["status"]; got != "success" {
t.Fatalf("tags.status = %v, want success", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/files/upload_all" {
t.Fatalf("tags.api_path = %v", got)
}
if got := tags["upload_mode"]; got != "singlepart" {
t.Fatalf("tags.upload_mode = %v, want singlepart", got)
}
if got := tags["resource_type"]; got != "file" {
t.Fatalf("tags.resource_type = %v, want file", got)
}
if got := tags["mount_point"]; got != driveUploadParentTypeExplorer {
t.Fatalf("tags.mount_point = %v, want %s", got, driveUploadParentTypeExplorer)
}
if got := tags["file_token"]; got != "file_report_ok" {
t.Fatalf("tags.file_token = %v, want file_report_ok", got)
}
}
func TestDriveUploadSmallFileReportFileEventOnError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reportStub := registerDriveReportStubWithMsg(t, reg, testDriveCapacityExpansionURL)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
withDriveWorkingDir(t, t.TempDir())
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "small.bin", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1061101 {
t.Fatalf("code = %d, want original 1061101", p.Code)
}
if !strings.Contains(p.Hint, testDriveCapacityExpansionURL) {
t.Fatalf("hint = %q, want capacity expansion URL", p.Hint)
}
tags := decodeDriveReportTags(t, reportStub)
if got := tags["status"]; got != "error" {
t.Fatalf("tags.status = %v, want error", got)
}
if got := tags["code"]; got != "1061101" {
t.Fatalf("tags.code = %v, want 1061101", got)
}
}
func TestDriveUploadLargeFileReportFileEventOnPrepareError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-large-prepare-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reportStub := registerDriveReportStubWithMsg(t, reg, testDriveCapacityExpansionURL)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
origDir, _ := os.Getwd()
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir() error: %v", err)
}
defer os.Chdir(origDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "large.bin", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 1061101 {
t.Fatalf("expected typed api error code 1061101, got %T (%v)", err, err)
}
if !strings.Contains(p.Hint, testDriveCapacityExpansionURL) {
t.Fatalf("hint = %q, want capacity expansion URL", p.Hint)
}
tags := decodeDriveReportTags(t, reportStub)
if got := tags["status"]; got != "error" {
t.Fatalf("tags.status = %v, want error", got)
}
if got := tags["upload_mode"]; got != "multipart" {
t.Fatalf("tags.upload_mode = %v, want multipart", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/files/upload_prepare" {
t.Fatalf("tags.api_path = %v, want upload_prepare", got)
}
}
func TestDriveUploadReportFileEventFailureKeepsUploadError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-keeps-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: driveReportFileEventPath,
Body: map[string]interface{}{"code": 500, "msg": "report rejected"},
Reusable: true,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{"code": 1001, "msg": "quota exceeded"},
})
withDriveWorkingDir(t, t.TempDir())
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "small.bin", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1001 {
t.Fatalf("code = %d, want original upload code 1001", p.Code)
}
if !strings.Contains(err.Error(), "quota exceeded") {
t.Fatalf("error lost original message: %v", err)
}
}

View File

@@ -23,6 +23,13 @@ const (
driveUploadParentTypeWiki = "wiki"
)
const (
driveUploadAllPath = "/open-apis/drive/v1/files/upload_all"
driveUploadPreparePath = "/open-apis/drive/v1/files/upload_prepare"
driveUploadPartPath = "/open-apis/drive/v1/files/upload_part"
driveUploadFinishPath = "/open-apis/drive/v1/files/upload_finish"
)
type driveUploadSpec struct {
FilePath string
FileToken string
@@ -123,8 +130,15 @@ var DriveUpload = common.Shortcut{
}
d := common.NewDryRunAPI().
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata").
POST("/open-apis/drive/v1/files/upload_all").
POST(driveUploadAllPath).
Body(body)
common.AppendUploadFileEventDryRun(d, runtime, common.LarkCLIFileEventMeta{
APIPath: driveUploadAllPath,
UploadMode: "singlepart",
ResourceType: "file",
ParentType: target.ParentType,
FileToken: "<file_token from upload response>",
})
d.POST("/open-apis/drive/v1/metas/batch_query").
Desc("Fetch the uploaded file's real access URL").
Body(map[string]interface{}{
@@ -253,26 +267,35 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
}
fd.AddFile("file", f)
meta := common.LarkCLIFileEventMeta{
APIPath: driveUploadAllPath,
UploadMode: "singlepart",
ResourceType: "file",
ParentType: target.ParentType,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
ApiPath: driveUploadAllPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
if errs.IsTyped(err) {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err)
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, wrapDriveNetworkErr(err, "upload failed: %v", err), meta)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned"), meta)
}
meta.FileToken = fileToken
common.ReportUploadFileEvent(runtime, meta)
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(data),
@@ -294,9 +317,17 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if existingFileToken != "" {
prepareBody["file_token"] = existingFileToken
}
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
meta := common.LarkCLIFileEventMeta{
APIPath: driveUploadPreparePath,
UploadMode: "multipart",
ResourceType: "file",
ParentType: target.ParentType,
}
prepareResult, err := runtime.CallAPITyped("POST", driveUploadPreparePath, nil, prepareBody)
if err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
uploadID := common.GetString(prepareResult, "upload_id")
@@ -306,15 +337,16 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
blockNum := int(blockNumF)
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
uploadID, blockSize, blockNum), meta)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
common.FormatSize(fileSize), common.FormatSize(blockSize), blockNum)
// Step 2: Upload parts
meta.APIPath = driveUploadPartPath
for seq := 0; seq < blockNum; seq++ {
offset := int64(seq) * blockSize
partSize := blockSize
@@ -324,7 +356,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partFile, err := runtime.FileIO().Open(filePath)
if err != nil {
return driveUploadResult{}, driveInputStatError(err)
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, driveInputStatError(err), meta)
}
fd := larkcore.NewFormdata()
@@ -335,39 +367,42 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
ApiPath: driveUploadPartPath,
Body: fd,
}, larkcore.WithFileUpload())
partFile.Close()
if err != nil {
if errs.IsTyped(err) {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err)
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err), meta)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
}
// Step 3: Finish
meta.APIPath = driveUploadFinishPath
finishBody := map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
}
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
finishResult, err := runtime.CallAPITyped("POST", driveUploadFinishPath, nil, finishBody)
if err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
fileToken := common.GetString(finishResult, "file_token")
if fileToken == "" {
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned"), meta)
}
meta.FileToken = fileToken
common.ReportUploadFileEvent(runtime, meta)
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(finishResult),

View File

@@ -17,9 +17,10 @@ import (
)
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -27,22 +28,37 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string {
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken.
func sheetMediaParentType(spreadsheetToken string) string {
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "fake_office_abc123",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"office_sheet_file"`) {
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
}
if strings.Contains(out, `"sheet_image"`) {
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
}
}
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
}
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
// parent_type=office_sheet_file instead of the native sheet_image.
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
dir := t.TempDir()
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
}
reg.Register(stub)
const officeToken = "fake_office_abc123"
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", officeToken,

View File

@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
}
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -63,21 +64,38 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -25,8 +25,9 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
// use "sheet_image"; imported "office" spreadsheets use either a legacy
// prefix or the interleaved "OFL0X" marker and must upload with
// "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
}
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
// to end (the Execute path the dry-run tests don't reach), asserting the
// parent_type that actually goes out on the wire is derived from the token: a
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
// (fake_office_-prefixed token) as office_sheet_file.
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
func TestUploadSheetImage_ParentType(t *testing.T) {
cases := []struct {
name string
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -16,14 +16,16 @@ metadata:
## 身份
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
按**日程归属**选身份:
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
```bash
# BAD — bot 身份查用户日程,返回空列表
lark-cli calendar +agenda --as bot
# GOOD — user 身份查日程
# 用户本人日程 → user
lark-cli calendar +agenda --as user
# bot 自建或参与的日程 → bot
lark-cli calendar +agenda --as bot
```
## Shortcuts
@@ -188,6 +190,8 @@ lark-cli contact +search-user --query <query> --as user
lark-cli im +chat-search --query <query> --as user
```
> 搜索用户接口不支持 bot 身份,必须用 `--as user`;搜到的 `ou_` open_id 用于日程参与人操作(如添加日程参与人)。
## 不在本 skill 范围
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)

View File

@@ -33,7 +33,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
| `--start <time>` | 是 | 开始时间ISO 8601`2026-03-12T14:00+08:00` |
| `--end <time>` | 是 | 结束时间ISO 8601 |
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ``### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL直接粘贴裸链接或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径``-`stdin读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`。|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 |
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。示例值"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
@@ -45,7 +45,6 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)

View File

@@ -50,12 +50,13 @@ lark-cli calendar +room-find \
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时提取数字放入此参数必须为正整数。 |
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |
## 规则
- 构造 `--attendee-ids` 前,先剔除 bot 参会人bot 不占席位、无偏好,不应参与会议室推荐。
- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出
- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。
- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。

View File

@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
```
规则:
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
- 参与人含**群组**:无需展开群组成员查询忙闲
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**

View File

@@ -45,7 +45,7 @@ lark-cli calendar +suggestion \
| ------------------------------- | ----- | ------------------------------------------------------------------- |
| `--start <time>` | 否 | 搜索区间开始时间(支持日期/ISO 8601等格式默认**当前时间** |
| `--end <time>` | 否 | 搜索区间结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔 |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |

View File

@@ -63,6 +63,7 @@ lark-cli calendar +update \
- 只想增删参会人或会议室时,不需要同时传 `--summary``--start``--end` 等日程字段。
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- bot 可作为合法参会人添加,无需剔除。
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -141,7 +141,7 @@ lark-cli auth login --domain slides
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
按需再读
调用相关命令前必须读取相关的文档以了解命令的使用方式
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
@@ -223,7 +223,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
Step 4: 审查 & 交付
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
```
@@ -314,7 +314,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML,用 `--presentation` 指定演示文稿的 `xml_presentation_id`,用 `--output` 把 XML 存到本地文件(必须是 CWD 内的相对路径,如 `.lark-slides/plan/<deck>/readback.xml` |
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |

View File

@@ -194,14 +194,13 @@
<xs:simpleType name="FontSizeType">
<xs:annotation>
<xs:documentation>
字体大小, 使用正整数, 单位px
示例12, 14, 16, 18, 20, 24, 28, 32 等
字体大小, 浮点数, 范围 [1, 4000], 单位px
示例:10, 10.5, 12, 14, 16, 18, 20, 24, 28, 32 等
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:positiveInteger">
<xs:minInclusive value="6"/>
<xs:maxInclusive value="400"/>
<xs:pattern value="[0-9]+"/>
<xs:restriction base="xs:double">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="4000"/>
</xs:restriction>
</xs:simpleType>
@@ -211,6 +210,52 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AutoStartAtType">
<xs:annotation>
<xs:documentation>
有序列表起始编号, 取值范围 [1, 32767]
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:positiveInteger">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="32767"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="BulletSizeType">
<xs:annotation>
<xs:documentation>
列表符号大小, 二选一:
- 百分比字符串(相对于文本字号), 取值范围 25%-400%, 如 "100%"
- 绝对像素值, 取值范围 6-400, 如 "14"
</xs:documentation>
</xs:annotation>
<xs:union>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="(2[5-9]|[3-9][0-9]|[1-3][0-9]{2}|400)%"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[6-9]|[1-9][0-9]|[1-3][0-9]{2}|400"/>
</xs:restriction>
</xs:simpleType>
</xs:union>
</xs:simpleType>
<xs:simpleType name="BulletCharType">
<xs:annotation>
<xs:documentation>
自定义列表符号, 如 "★", "→", "✓", "◆", 也支持 emoji
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
<xs:maxLength value="8"/>
</xs:restriction>
</xs:simpleType>
<!-- 文本类型枚举 -->
<xs:simpleType name="TextType">
<xs:annotation>
@@ -232,6 +277,35 @@
</xs:restriction>
</xs:simpleType>
<!-- 动态文本字段类型枚举 -->
<xs:simpleType name="FieldType">
<xs:annotation>
<xs:documentation>
动态文本字段类型:
- slidenum: 当前幻灯片页码
- datetime: 默认日期时间格式
- datetime1-datetime13: 预定义日期时间格式
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="slidenum"><xs:annotation><xs:documentation>当前幻灯片页码</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime"><xs:annotation><xs:documentation>浏览器默认日期格式, 例如 2026/7/14</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime1"><xs:annotation><xs:documentation>日期格式 M/D/YYYY, 例如 10/12/2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime2"><xs:annotation><xs:documentation>日期格式 dddd, MMMM D, YYYY, 例如 Friday, October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime3"><xs:annotation><xs:documentation>日期格式 D MMMM YYYY, 例如 12 October 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime4"><xs:annotation><xs:documentation>日期格式 MMMM D, YYYY, 例如 October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime5"><xs:annotation><xs:documentation>日期格式 D-MMM-YY, 例如 12-Oct-07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime6"><xs:annotation><xs:documentation>日期格式 MMMM YY, 例如 October 07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime7"><xs:annotation><xs:documentation>日期格式 MMM-YY, 例如 Oct-07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime8"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm A, 例如 10/12/2007 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime9"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm:ss A, 例如 10/12/2007 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime10"><xs:annotation><xs:documentation>时间格式 HH:mm, 例如 16:28</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime11"><xs:annotation><xs:documentation>时间格式 HH:mm:ss, 例如 16:28:34</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime12"><xs:annotation><xs:documentation>时间格式 h:mm A, 例如 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime13"><xs:annotation><xs:documentation>时间格式 h:mm:ss A, 例如 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 文本对齐 -->
<xs:simpleType name="TextAlignType">
<xs:restriction base="xs:string">
@@ -781,21 +855,64 @@
<xs:attribute name="heightScale" type="sml:ArrowScaleType" use="optional"/>
</xs:complexType>
<!-- 裁剪方位枚举类型 -->
<xs:simpleType name="CropAnchorType">
<xs:annotation>
<xs:documentation>
裁剪方位枚举, 用于指定保留原图的哪个区域
- top: 保留顶部, 裁掉底部多余部分
- bottom: 保留底部, 裁掉顶部多余部分
- left: 保留左侧, 裁掉右侧多余部分
- right: 保留右侧, 裁掉左侧多余部分
居中场景不需要设置 anchor, 不设置 offset 即为默认居中裁剪
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="top"><xs:annotation><xs:documentation>保留顶部, 裁掉底部多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="bottom"><xs:annotation><xs:documentation>保留底部, 裁掉顶部多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="left"><xs:annotation><xs:documentation>保留左侧, 裁掉右侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="right"><xs:annotation><xs:documentation>保留右侧, 裁掉左侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 裁剪类型定义 -->
<xs:complexType name="CropType">
<xs:annotation>
<xs:documentation>
裁剪配置: 原图填充到预裁剪区域再根据offset裁出最终尺寸
裁剪配置: 原图裁剪到目标尺寸 (img 元素的 width × height)
可选属性:
type: 裁剪形状默认rect
leftOffset, rightOffset, topOffset, bottomOffset: 边缘偏移量(px)。正值向内裁剪负值向外扩展留白0值对齐边缘
presetHandlers: 控制点配置对应ECMA预设形状的控制点。单个或多个数字多个用逗号分隔。示例: type="rect"且presetHandlers="60"时为圆角矩形圆角半径60px
type: 裁剪形状, 默认 rect
anchor: 裁剪方位 (top/bottom/left/right), 参见 CropAnchorType
leftOffset / rightOffset / topOffset / bottomOffset: 四向偏移量 (px), 正值向内裁剪、负值向外扩展留白、0对齐边缘
presetHandlers: 控制点配置, 对应 ECMA 预设形状的控制点。单个或多个数字, 多个用逗号分隔。
示例: type="rect" 且 presetHandlers="60" 时为圆角矩形, 圆角半径 60px
说明: 指定offset时若预裁剪尺寸与原图比例不一致会产生拉伸变形。无法确定原图比例时不要指定offset
【推荐用法】使用 anchor 指定裁剪方位:
- 不设置 anchor 时: 默认按图片居中裁剪
- 设置 anchor 时: 按指定方位裁剪, 例如 anchor="top" 表示保留顶部、裁掉底部多余部分
- 使用 anchor 后, 不需要再设置 offset
- anchor 模式下原图按等比缩放后裁剪, 不会发生拉伸或压缩
【进阶用法】使用 offset 精细控制裁剪边界:
- 适用于用户在编辑器中手动调整裁剪、或从外部协议导入的场景
- 原图先填充到预裁剪区域, 再根据 offset 从四边裁出最终尺寸
- 注意: 若预裁剪尺寸与原图比例不一致会产生拉伸变形; 无法确定原图比例时, 不要指定 offset
【优先级】
如果同时设置了 anchor 和 offset, 以 anchor 为准, offset 被忽略
典型用法:
<crop/> 居中裁剪 (默认行为)
<crop anchor="top"/> 保留顶部
<crop anchor="left"/> 保留左侧
<crop type="rect" presetHandlers="60"/> 圆角矩形裁剪, 默认居中
</xs:documentation>
</xs:annotation>
<xs:attribute name="type" type="sml:ShapeType" use="optional" default="rect"/>
<xs:attribute name="anchor" type="sml:CropAnchorType" use="optional"/>
<xs:attribute name="leftOffset" type="xs:double" use="optional"/>
<xs:attribute name="rightOffset" type="xs:double" use="optional"/>
<xs:attribute name="topOffset" type="xs:double" use="optional"/>
@@ -1042,6 +1159,10 @@
- underline: content 级别是否下划线
- list: content 级别列表类型 bullet/number
- listStyle: content 级别列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)如 "100%", 或绝对像素值(取值范围 6-400如 "14"
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作为后代段落的初始计数器, 子元素 &lt;p&gt;/&lt;ol&gt; 可通过自身 autoStartAt 重置
- bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
- anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
- autoFit: 控制文本编辑溢出时处理策略
- baseline: 上标/下标, 相较于文本基线的偏移量
@@ -1049,6 +1170,13 @@
注意如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值
autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
&lt;content autoStartAt="5"&gt;
&lt;p list="number"&gt;A&lt;/p&gt; &lt;!-- A=5, 继承 content 初始值 --&gt;
&lt;p list="number" autoStartAt="10"&gt;B&lt;/p&gt; &lt;!-- B=10, 本段显式重置 --&gt;
&lt;p list="number"&gt;C&lt;/p&gt; &lt;!-- C=11, 沿用前序计数器递增 --&gt;
&lt;/content&gt;
子元素:
- p: 段落元素
- ul: 无序列表元素
@@ -1085,6 +1213,10 @@
<xs:attribute name="underline" type="xs:boolean" />
<xs:attribute name="list" type="sml:ListType" default="none"/>
<xs:attribute name="listStyle" type="sml:ListStyleType" />
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
<xs:attribute name="anchorCenter" type="xs:boolean" default="false" /> <!-- 控制竖排文字是否在垂直方向保持居中 -->
<xs:attribute name="autoFit" type="sml:AutoFitType" default="no-auto-fit" />
<xs:attribute name="wrap" type="xs:boolean" default="true" />
@@ -1096,9 +1228,9 @@
<xs:annotation>
<xs:documentation>
段落容器, 支持富文本内容
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline)
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline/formula/field)
内联元素嵌套:所有内联元素均可包含纯文本或其他内联元素,以实现复杂的格式组合
元素自嵌套除a元素外其余内联元素支持自身嵌套当shadow和outline自嵌套时渲染效果遵循就近原则以内层定义的样式为准
元素自嵌套除a/formula元素外其余内联元素支持自身嵌套当shadow和outline自嵌套时渲染效果遵循就近原则以内层定义的样式为准
空格处理规则:
- 文本内的连续空格会被合并为单个空格
@@ -1119,6 +1251,8 @@
- a: 超链接
- shadow: 文本阴影
- outline: 文本轮廓
- formula: 科学公式(支持数学、物理等)
- field: 动态文本字段,元素内容作为不支持动态字段时的降级文本
属性说明:
- textAlign: 文本对齐方式
- lineSpacing: 行间距
@@ -1127,6 +1261,10 @@
- level: 段落级别, 取值范围 [1,10]
- list: 列表类型(bullet/number)
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 显式指定时从当前段落起重置计数器, 未指定时沿用同一 content 内的前序计数器
- bulletChar: 自定义列表符号字符, 可选
- marginLeft: 段落左侧缩进宽度
- indent: 首行缩进宽度
</xs:documentation>
@@ -1134,6 +1272,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1142,6 +1281,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="textAlign" type="sml:TextAlignType" />
<xs:attribute name="lineSpacing" type="sml:LineSpacingType" default="multiple:1.5"/>
@@ -1151,6 +1291,10 @@
<xs:attribute name="level" type="sml:LevelType" default="1"/>
<xs:attribute name="list" type="sml:ListType" default="none"/>
<xs:attribute name="listStyle" type="sml:ListStyleType"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
<xs:attribute name="marginLeft" type="xs:double" use="optional" />
<xs:attribute name="indent" type="sml:NonNegativeDouble" use="optional"/>
</xs:complexType>
@@ -1160,7 +1304,14 @@
<!-- 无序列表 -->
<xs:element name="ul">
<xs:annotation>
<xs:documentation>无序列表</xs:documentation>
<xs:documentation>
无序列表
属性说明:
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- bulletChar: 自定义列表符号字符, 可选, 设置后覆盖 listStyle 的符号
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
@@ -1173,13 +1324,23 @@
</xs:element>
</xs:sequence>
<xs:attribute name="listStyle" type="sml:UnorderedListStyle" default="circle-hollow-square"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
</xs:complexType>
</xs:element>
<!-- 有序列表 -->
<xs:element name="ol">
<xs:annotation>
<xs:documentation>有序列表, 可指定序号</xs:documentation>
<xs:documentation>
有序列表, 可指定序号
属性说明:
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作用于本列表组的计数器初始值, 子元素 &lt;li@index&gt; 可覆盖单项编号
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
@@ -1193,6 +1354,9 @@
</xs:element>
</xs:sequence>
<xs:attribute name="listStyle" type="sml:OrderedListStyle" default="number-lower-alpha-lower-roman"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
</xs:complexType>
</xs:element>
@@ -1383,7 +1547,7 @@
alpha: 不透明度[0, 1]
可选子元素:
crop: 裁剪。无标签或所有offset未设置时从左上角自适应裁到width×height
crop: 裁剪。无标签 / 空标签 / 仅设 anchor 时按等比缩放后裁剪到 width×height; anchor 指定保留方位 (top/bottom/left/right), 不设 anchor 即居中裁剪; offset 用于精细控制
reflection: 倒影。无标签代表无倒影,空标签代表使用默认样式
shadow: 阴影。无标签代表无阴影,空标签代表使用默认样式
border: 边框。无标签代表无边框,空标签代表使用默认样式(颜色: rgba(43, 47, 54, 1), 宽度: 2)
@@ -1500,7 +1664,7 @@
td 子元素:
- borderTop/borderRight/borderBottom/borderLeft: 单元格边框样式, 无border标签代表无边框, 空border标签代表使用默认样式(实线边框, 颜色为rgba(221, 222, 223, 1), 宽度为1)
- fill: 单元格填充样式, 无fill标签代表不填充, 空fill标签代表使用默认样式(默认颜色填充, 颜色为rgba(255, 255, 255, 1))
- content: 单元格内容
- content: 单元格内容。内容默认不反向修改表格几何尺寸; 当内容高度大于当前行高时, 需要手动修改行高
</xs:documentation>
</xs:annotation>
<xs:complexType>
@@ -1558,13 +1722,15 @@
<xs:complexType/>
</xs:element>
<xs:element name="strong">
<xs:element name="field">
<xs:annotation>
<xs:documentation>粗体/加重文本</xs:documentation>
<xs:documentation>
动态文本字段。
type 属性描述动态语义,元素内容是静态降级文本,可包含行内样式元素。
</xs:documentation>
</xs:annotation>
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1574,6 +1740,70 @@
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
</xs:choice>
<xs:attribute name="type" type="sml:FieldType" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="formula">
<xs:annotation>
<xs:documentation>
通用公式元素。
用于展示各类科学公式。
结构说明:
- 必须从支持的公式格式中选择且仅选择一种作为子元素。
- 当前版本支持格式:&lt;latex&gt;
示例:
- 基础公式:
&lt;formula&gt;
&lt;latex&gt;&lt;![CDATA[ E = mc^2 ]]&gt;&lt;/latex&gt;
&lt;/formula&gt;
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:choice minOccurs="1" maxOccurs="1">
<xs:element name="latex">
<xs:annotation>
<xs:documentation>
LaTeX 格式的公式内容。
本元素包含的 LaTeX 字符串必须严格符合附件中定义的宏集范围。
内容语法:
- 语法范围:仅使用附件白名单中明确支持的宏。
- 表达建议:优先使用基础运算符、分式(\frac)、根号(\sqrt)、矩阵(matrix)等标准数学环境。
- 格式要求:必须使用 CDATA 包裹内容,且 CDATA 内部严禁进行 XML 转义(如 &amp;lt;, &amp;amp;)。
- 空白处理:解析器将保留 CDATA 内的所有换行和缩进,建议利用此特性保持 LaTeX 源码的结构化和可读性。
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string"/>
</xs:simpleType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
<xs:element name="strong">
<xs:annotation>
<xs:documentation>粗体/加重文本</xs:documentation>
</xs:annotation>
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
<xs:element ref="sml:span"/>
<xs:element ref="sml:del"/>
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1590,6 +1820,7 @@
<xs:extension base="sml:ShadowType">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1598,6 +1829,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:extension>
</xs:complexContent>
@@ -1617,6 +1849,7 @@
<xs:extension base="sml:OutlineType">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1625,6 +1858,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:extension>
</xs:complexContent>
@@ -1638,6 +1872,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1646,6 +1881,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1657,6 +1893,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1665,6 +1902,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1676,6 +1914,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1684,6 +1923,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1698,6 +1938,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1706,6 +1947,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="color" type="sml:Color" use="optional"/>
<xs:attribute name="backgroundColor" type="sml:Color" use="optional"/>
@@ -1729,6 +1971,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1736,6 +1979,7 @@
<xs:element ref="sml:del"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="href" use="required">
<xs:simpleType>
@@ -1823,36 +2067,116 @@
<!-- 有序列表样式枚举 -->
<xs:simpleType name="OrderedListStyle">
<xs:annotation>
<xs:documentation>有序列表样式</xs:documentation>
<xs:documentation>
有序列表样式
分为两类:
1. 复合样式(按层级循环不同格式):如 number-lower-alpha-lower-roman 表示第1级用数字、第2级用小写字母、第3级用小写罗马超过层级数后循环
2. 单一样式(所有层级使用同一格式,不循环):以 PPTX 标准 scheme 命名,如 alpha-lc-paren-both 表示所有层级都用 (a)(b)(c) 格式
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<!-- 复合样式(按层级循环) -->
<xs:enumeration value="number-lower-alpha-lower-roman"><xs:annotation><xs:documentation>1. a. i. - 数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="number-lower-alpha-lower-roman-paren"><xs:annotation><xs:documentation>1) a) i) - 带括号版本</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hierarchical-number"><xs:annotation><xs:documentation>1. 1.1. 1.1.1. - 多级数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="upper-alpha-lower-alpha-lower-roman"><xs:annotation><xs:documentation>A. a. i. - 大写字母/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="upper-roman-upper-alpha-number"><xs:annotation><xs:documentation>I. A. 1. - 大写罗马/大写字母/数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="zero-padded-lower-alpha-lower-roman"><xs:annotation><xs:documentation>01. a. i. - 补零数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation> 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation>圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="lower-alpha-paren"><xs:annotation><xs:documentation>a) b) c) - 小写字母带括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="lower-alpha-dot"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="chinese-formal"><xs:annotation><xs:documentation>一、二、三、 - 中文数字</xs:documentation></xs:annotation></xs:enumeration>
<!-- 单一样式(所有层级使用同一格式,不随层级循环) -->
<!-- 拉丁字母 Latin -->
<xs:enumeration value="alpha-lc-paren-both"><xs:annotation><xs:documentation>(a) (b) (c) - 小写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-paren-both"><xs:annotation><xs:documentation>(A) (B) (C) - 大写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-lc-paren-r"><xs:annotation><xs:documentation>a) b) c) - 小写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-paren-r"><xs:annotation><xs:documentation>A) B) C) - 大写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-lc-period"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-period"><xs:annotation><xs:documentation>A. B. C. - 大写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 阿拉伯数字 Arabic Numeral -->
<xs:enumeration value="arabic-paren-both"><xs:annotation><xs:documentation>(1) (2) (3) - 数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-paren-r"><xs:annotation><xs:documentation>1) 2) 3) - 数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-period"><xs:annotation><xs:documentation>1. 2. 3. - 数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-plain"><xs:annotation><xs:documentation>1 2 3 - 纯数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-db-period"><xs:annotation><xs:documentation>- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation> - 全角纯数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic1-minus"><xs:annotation><xs:documentation>أ- ب- ت- - 阿拉伯语字母(现代序)带后横线</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic2-minus"><xs:annotation><xs:documentation>-أ- -ب- -ج- - 阿拉伯语字母(Abjadi序)带双横线</xs:documentation></xs:annotation></xs:enumeration>
<!-- 罗马数字 Roman -->
<xs:enumeration value="roman-lc-paren-both"><xs:annotation><xs:documentation>(i) (ii) (iii) - 小写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-paren-both"><xs:annotation><xs:documentation>(I) (II) (III) - 大写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-lc-paren-r"><xs:annotation><xs:documentation>i) ii) iii) - 小写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-paren-r"><xs:annotation><xs:documentation>I) II) III) - 大写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-lc-period"><xs:annotation><xs:documentation>i. ii. iii. - 小写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-period"><xs:annotation><xs:documentation>I. II. III. - 大写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 圆圈数字 Circle -->
<xs:enumeration value="circle-num-db-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-num-wd-black-plain"><xs:annotation><xs:documentation>❶ ❷ ❸ - 实心圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-num-wd-white-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字1-10 循环, 字形与 circle-num-db-plain 相同但超过 10 后不降级为纯数字)</xs:documentation></xs:annotation></xs:enumeration>
<!-- 东亚 East Asian -->
<xs:enumeration value="ea1-chs-period"><xs:annotation><xs:documentation>一. 二. 三. - 简体中文带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-chs-plain"><xs:annotation><xs:documentation>一 二 三 - 简体中文</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-cht-period"><xs:annotation><xs:documentation>一. 二. 三. - 繁体中文带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-cht-plain"><xs:annotation><xs:documentation>一 二 三 - 繁体中文</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-chs-db-period"><xs:annotation><xs:documentation>一.二.三.- CJK汉字数字带全角点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-kor-plain"><xs:annotation><xs:documentation>一 二 三 - CJK汉字数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-kor-period"><xs:annotation><xs:documentation>一. 二. 三. - CJK汉字数字带半角点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 希伯来语 Hebrew -->
<xs:enumeration value="hebrew2-minus"><xs:annotation><xs:documentation>א- ב- ג- - 希伯来字母带横线</xs:documentation></xs:annotation></xs:enumeration>
<!-- 泰语 Thai -->
<xs:enumeration value="thai-alpha-period"><xs:annotation><xs:documentation>ก. ข. ค. - 泰语字母带点(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-alpha-paren-r"><xs:annotation><xs:documentation>ก) ข) ค) - 泰语字母带右括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-alpha-paren-both"><xs:annotation><xs:documentation>(ก) (ข) (ค) - 泰语字母带双括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-period"><xs:annotation><xs:documentation>๑. ๒. ๓. - 泰语数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-paren-r"><xs:annotation><xs:documentation>๑) ๒) ๓) - 泰语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-paren-both"><xs:annotation><xs:documentation>(๑) (๒) (๓) - 泰语数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
<!-- 印地语 Hindi -->
<xs:enumeration value="hindi-alpha-period"><xs:annotation><xs:documentation>अ. आ. इ. - 印地语元音字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-num-period"><xs:annotation><xs:documentation>१. २. ३. - 印地语数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-num-paren-r"><xs:annotation><xs:documentation>१) २) ३) - 印地语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-alpha1-period"><xs:annotation><xs:documentation>क. ख. ग. - 印地语辅音字母带点</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 无序列表样式枚举 -->
<xs:simpleType name="UnorderedListStyle">
<xs:annotation>
<xs:documentation>无序列表样式</xs:documentation>
<xs:documentation>
无序列表样式
分为两类:
1. 复合样式(按层级循环不同图标):如 circle-hollow-square 表示第1级实心圆、第2级空心圆、第3级实心方形超过层级数后循环
2. 单一样式(所有层级使用同一图标,不循环):以 pptx- 前缀命名,如 pptx-circle 表示所有层级都用 ● 实心圆
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<!-- 复合样式(按层级循环) -->
<xs:enumeration value="circle-hollow-square"><xs:annotation><xs:documentation>实心圆 空心圆 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hollow-square-all"><xs:annotation><xs:documentation>空心方形 空心方形 空心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="star-hollow-circle-square"><xs:annotation><xs:documentation>实心五角星 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="triangle-hollow-circle-square"><xs:annotation><xs:documentation>三角形 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="solid-square-all"><xs:annotation><xs:documentation>实心方形 实心方形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="solid-diamond-all"><xs:annotation><xs:documentation>实心菱形 实心菱形 实心菱形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="check-all"><xs:annotation><xs:documentation>对勾 对勾 对勾</xs:documentation></xs:annotation></xs:enumeration>
<!-- 单一样式(所有层级使用同一图标,不随层级循环) -->
<xs:enumeration value="pptx-circle"><xs:annotation><xs:documentation>● 实心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square"><xs:annotation><xs:documentation>■ 方块(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-diamond"><xs:annotation><xs:documentation>◆ 菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square-empty"><xs:annotation><xs:documentation>□ 空心方框(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-check"><xs:annotation><xs:documentation>✓ 对勾(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-triangle"><xs:annotation><xs:documentation>► 右三角(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-bullet"><xs:annotation><xs:documentation>• 小圆点(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-circle-empty"><xs:annotation><xs:documentation>○ 空心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-diamond-empty"><xs:annotation><xs:documentation>◇ 空心菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-arrow-right"><xs:annotation><xs:documentation>➔ 右箭头(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-star"><xs:annotation><xs:documentation>★ 星形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square-shadow"><xs:annotation><xs:documentation>❑ 带右下阴影的 3D 方框(所有层级,对应 PPTX Wingdings 'q'</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
@@ -2096,6 +2420,19 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartGradientKindType">
<xs:annotation>
<xs:documentation>
图表渐变类型
可选值: linear(线性渐变) | radial(径向渐变)
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="linear"/>
<xs:enumeration value="radial"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartRadarShapeType">
<xs:annotation>
<xs:documentation>
@@ -2217,6 +2554,7 @@
属性:
- textAlign: 文本对齐方式(left|center|right), 默认left
- fontFamily: 字体族名称,仅图表根级主标题/副标题支持;坐标轴标题不支持
- fontSize: 字号大小
- bold: 是否加粗
- italic: 是否斜体, 默认false
@@ -2230,6 +2568,7 @@
<xs:complexContent>
<xs:extension base="sml:ChartFontStyleType">
<xs:attribute name="textAlign" type="sml:ChartTextAlignType" use="optional" default="left"/>
<xs:attribute name="fontFamily" type="sml:FontFamilyType" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
@@ -2298,10 +2637,10 @@
图表背景配置
属性:
- color: 背景颜色, 默认透明 rgba(0,0,0,0)
- color: 背景颜色,省略时使用图表默认背景;无填充可使用透明 rgba(0,0,0,0)
</xs:documentation>
</xs:annotation>
<xs:attribute name="color" type="sml:SolidColor" use="optional" default="rgb(255, 255, 255)"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartBorderType">
@@ -2311,7 +2650,7 @@
属性:
- color: 边框颜色,默认 rgb(222, 224, 227)
- width: 边框宽度(像素), 默认 1
- width: 边框宽度(像素), 默认 1无边框可设置为0或不设置chartBorder
- style: 边框样式(solid|dashed|dotted), 默认 solid
- radius: 圆角半径(像素), 默认 6
</xs:documentation>
@@ -2322,6 +2661,61 @@
<xs:attribute name="radius" type="xs:nonNegativeInteger" use="optional" />
</xs:complexType>
<xs:complexType name="ChartGradientStopType">
<xs:annotation>
<xs:documentation>
图表渐变色标
属性:
- offset: 色标位置比例[0,1]
- color: 色标颜色
- opacity: 色标透明度[0,1]
</xs:documentation>
</xs:annotation>
<xs:attribute name="offset" type="sml:RatioType" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="required"/>
<xs:attribute name="opacity" type="sml:RatioType" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartGradientStopsType">
<xs:annotation>
<xs:documentation>
图表渐变色标列表至少需要2个色标
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stop" type="sml:ChartGradientStopType" minOccurs="2" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChartGradientType">
<xs:annotation>
<xs:documentation>
图表渐变配置
属性:
- type: 渐变类型(linear|radial)
- x0/y0/x1/y1: 线性渐变起止点坐标
- r0/r1: 径向渐变半径
- gradientMethod: 渐变算法/插值方式
子元素:
- stops: 渐变色标列表
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stops" type="sml:ChartGradientStopsType" minOccurs="1"/>
</xs:sequence>
<xs:attribute name="type" type="sml:ChartGradientKindType" use="required"/>
<xs:attribute name="x0" type="xs:double" use="optional"/>
<xs:attribute name="y0" type="xs:double" use="optional"/>
<xs:attribute name="x1" type="xs:double" use="optional"/>
<xs:attribute name="y1" type="xs:double" use="optional"/>
<xs:attribute name="r0" type="xs:double" use="optional"/>
<xs:attribute name="r1" type="xs:double" use="optional"/>
<xs:attribute name="gradientMethod" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartColorThemeType">
<xs:annotation>
<xs:documentation>
@@ -2431,12 +2825,16 @@
- size: 该系列所有点的大小
子元素:
- fillGradient: 该系列所有点的填充渐变(可选)
- strokeGradient: 该系列所有点的边框/描边渐变(可选)
- chartPoint: 单个数据点配置(可选, 多个), 用于覆盖特定点的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalPointsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartPoint" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2448,8 +2846,14 @@
- color: 该点的颜色
- shape: 该点的形状(circle|square|triangle|diamond|rect)
- size: 该点的大小(像素)
子元素:
- fillGradient: 该点填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="shape" type="sml:ChartPointShapeType" use="optional"/>
@@ -2462,7 +2866,7 @@
</xs:complexType>
<!-- 线条配置 -->
<xs:complexType name="ChartLineType">
<xs:complexType name="ChartGlobalLineType">
<xs:annotation>
<xs:documentation>
图表全局线条配置(第一层:所有系列的默认样式)
@@ -2479,8 +2883,27 @@
<xs:attribute name="style" type="sml:ChartLineStyleType" use="optional" default="solid"/>
</xs:complexType>
<xs:complexType name="ChartSeriesLineType">
<xs:annotation>
<xs:documentation>
图表系列线条配置(第二层:单系列统一配置)
继承ChartGlobalLineType的所有属性
子元素:
- strokeGradient: 该系列线条渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalLineType">
<xs:sequence>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 面积配置 -->
<xs:complexType name="ChartAreaType">
<xs:complexType name="ChartGlobalAreaType">
<xs:annotation>
<xs:documentation>
图表全局面积配置(第一层:所有系列的默认填充样式)
@@ -2493,6 +2916,25 @@
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartSeriesAreaType">
<xs:annotation>
<xs:documentation>
图表系列面积配置(第二层:单系列统一配置)
继承ChartGlobalAreaType的所有属性
子元素:
- fillGradient: 该系列面积填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalAreaType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 柱子配置 -->
<xs:complexType name="ChartGlobalBarsType">
<xs:annotation>
@@ -2533,12 +2975,16 @@
- borderStyle: 该系列所有柱子的边框样式
子元素:
- fillGradient: 该系列所有柱子的填充渐变(可选)
- strokeGradient: 该系列所有柱子的边框渐变(可选)
- chartBar: 单个柱子配置(可选, 多个), 用于覆盖特定柱子的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalBarsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartBar" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2551,8 +2997,14 @@
- borderColor: 该柱子的边框颜色
- borderWidth: 该柱子的边框宽度(像素)
- borderStyle: 该柱子的边框样式(solid|dashed|dotted)
子元素:
- fillGradient: 该柱子的填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2577,8 +3029,14 @@
- offsetRadius: 扇区径向偏移比例[0,1], 用于突出显示
- borderColor: 扇区边框颜色
- color: 扇区填充颜色
子元素:
- fillGradient: 扇区填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="offsetRadius" type="sml:RatioType" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2598,10 +3056,12 @@
- startAngle: 起始角度[0,360), 控制第一个扇区的起始位置, 默认0
子元素:
- fillGradient: 所有扇区的统一填充渐变(可选)
- chartSector: 单个扇区配置(可选, 多个), 用于定制特定扇区
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartSector" type="sml:ChartSectorType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2648,8 +3108,8 @@
</xs:annotation>
<xs:sequence>
<xs:element name="chartPoints" type="sml:ChartSeriesPointsType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartSeriesLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartSeriesAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartSeriesBarsType" minOccurs="0"/>
<xs:element name="chartSectors" type="sml:ChartSectorsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
@@ -2850,8 +3310,8 @@
</xs:annotation>
<xs:all>
<xs:element name="chartPoints" type="sml:ChartGlobalPointsType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartGlobalLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartGlobalAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartGlobalBarsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
<xs:element name="chartSeriesList" type="sml:ChartSeriesListType" minOccurs="0"/>

View File

@@ -129,6 +129,15 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<a>`
- `<shadow>`
- `<outline>`
- `<formula>`
公式写法:
```xml
<p>公式:<formula><latex><![CDATA[ E = mc^2 ]]></latex></formula></p>
```
`<formula>` 是内联元素;当前只支持一个 `<latex>` 子元素。LaTeX 内容必须放在 `CDATA` 中,且 `CDATA` 内不要写 XML 转义;宏只使用服务端支持范围内的写法,优先用基础运算符、`\frac``\sqrt``matrix`
示例:
@@ -312,6 +321,36 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
`<chart>` 直接子元素必须有 `<chartPlotArea>`(绘图区)和 `<chartData>`(数据);`<chartTitle>``<chartSubTitle>``<chartStyle>``<chartLegend>``<chartTooltip>` 可选,如果想不展示标题、副标题、图例或悬浮提示,省略相应元素标签即可。
`<chartStyle>` 常用子元素:
- `<chartBackground>``color` 省略时由渲染端决定默认背景;需要完全透明请显式写 `color="rgba(0, 0, 0, 0)"`
- `<chartBorder>`:无边框可写 `width="0"`,或直接不写 `<chartBorder>` 元素
#### 图表渐变 `<fillGradient>` / `<strokeGradient>`
图表支持渐变填充/描边,`<fillGradient>` 用于面积、柱子、数据点、扇区填充,`<strokeGradient>` 用于线条、数据点边框、柱子边框。渐变只能挂在系列级或单元素级,不要挂在 `<chartPlot>` 全局层。
可挂载位置:
- 系列级:`<chartBars>` / `<chartPoints>` 支持 `<fillGradient>``<strokeGradient>``<chartLine>` 只支持 `<strokeGradient>``<chartArea>` / `<chartSectors>` 只支持 `<fillGradient>`
- 单元素级:`<chartBar index="...">` / `<chartPoint index="...">` / `<chartSector index="...">` 只支持 `<fillGradient>`
- 全局级:`<chartPlot>` 下的 `<chartLines>` / `<chartAreas>` / `<chartBars>` / `<chartPoints>` 不支持渐变
结构要点:`type` 必填,可为 `linear``radial``linear``x0` / `y0` / `x1` / `y1``radial``r0` / `r1``<stops>` 至少包含 2 个 `<stop>``offset``opacity` 取值均为 `[0, 1]`
```xml
<chartSeries index="1">
<chartBars>
<fillGradient type="linear" x0="0" y0="0" x1="0" y1="1">
<stops>
<stop offset="0" color="rgb(28, 71, 120)"/>
<stop offset="1" color="rgb(28, 71, 120)" opacity="0.3"/>
</stops>
</fillGradient>
</chartBars>
</chartSeries>
```
隐藏 `<chart>` 的图例只能通过不写或删除 `<chartLegend>` 实现,`<chartLegend>` 不支持 `position="none"`
详细用法见 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。

View File

@@ -24,6 +24,12 @@ lark-cli task +create \
lark-cli task +create \
--summary "Buy milk"
# Create a milestone by passing an API field without a named flag
lark-cli task +create \
--summary "Release v2.0" \
--due "2026-08-15" \
--data '{"is_milestone":true}'
# Preview the API call without executing
lark-cli task +create --summary "Test Task" --dry-run
```
@@ -39,8 +45,11 @@ lark-cli task +create --summary "Test Task" --dry-run
| `--due <time>` | No | Due date. Supports ISO 8601, `YYYY-MM-DD`, relative time (e.g., `+2d`), or ms timestamp. `YYYY-MM-DD` and relative time will automatically set it as an all-day task. |
| `--tasklist-id <id>` | No | The GUID of the tasklist, or a full AppLink URL (the CLI will automatically extract the `guid` parameter from the URL). |
| `--idempotency-key <key>` | No | Client token to ensure idempotency of the request. |
| `--data <json>` | No | JSON object merged into the task create request for API fields without dedicated flags, such as `{"is_milestone":true}`. Explicit named flags override same-named fields in this object. |
| `--dry-run` | No | Preview the API call (JSON payload) without actually creating the task. |
Use `lark-cli schema task.tasks.create` to confirm that an extra field is supported before passing it through `--data`. Prefer this shortcut over the raw `tasks create` command when `--data` can express the request. Do not assume that other shortcuts support `--data`; check each shortcut's `--help` output first.
## Workflow
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.

View File

@@ -111,18 +111,29 @@ func TestBase_RoleWorkflow(t *testing.T) {
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
pollTimeout := 30 * time.Second
pollCtx, pollCancel := context.WithTimeout(ctx, pollTimeout)
defer pollCancel()
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
err = clie2e.WaitForCondition(pollCtx, clie2e.WaitOptions{
Timeout: pollTimeout,
Interval: 3 * time.Second,
}, func() (bool, error) {
getResult, getErr := clie2e.RunCmd(pollCtx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
if getErr != nil {
return false, getErr
}
if getResult.ExitCode != 0 {
return false, getResult.RunErr
}
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
return gjson.Valid(rolePayload) && gjson.Get(rolePayload, "role_name").String() == updatedRoleName, nil
})
require.NoError(t, err, "role name should converge to %q", updatedRoleName)
})
}

View File

@@ -2,13 +2,14 @@
## Metrics
- Denominator: 32 leaf commands
- Covered: 13
- Coverage: 40.6%
- Covered: 14
- Coverage: 43.8%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_ImportWorkflow: proves `drive +import` against the real backend. It imports a temporary Markdown file as docx, waits for the async task when needed, verifies the returned document token, and deletes the imported document during cleanup.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
@@ -34,7 +35,7 @@
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| | drive +import | shortcut | | none | no import workflow yet |
| | drive +import | shortcut | drive_import_dryrun_test.go::TestDriveImportDryRunFolderTokenWikiProbe + drive_import_workflow_test.go::TestDrive_ImportWorkflow | `--file`; `--type docx`; upload report request shape; async ticket polling; imported token cleanup | dry-run pins the upload/report/import request chain; live workflow imports a real Markdown fixture and deletes the resulting docx |
| ✕ | drive +move | shortcut | | none | no move workflow yet |
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |

View File

@@ -51,7 +51,30 @@ func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("data.api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("data.api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.2.method").String(); got != "POST" {
t.Fatalf("data.api.2.method = %q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.2.url").String(); got != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("data.api.2.url = %q, want report_file_event\nstdout:\n%s", got, out)
}
reportChecks := map[string]string{
"api.2.body.file_scene": "lark-cli",
"api.2.body.scene": "upload",
"api.2.body.operation": "upload",
"api.2.body.tags.api_path": "/open-apis/drive/v1/medias/upload_all",
"api.2.body.tags.command": "drive +import",
"api.2.body.tags.upload_mode": "singlepart",
"api.2.body.tags.resource_type": "media",
"api.2.body.tags.status": "success",
"api.2.body.tags.mount_point": "ccm_import_open",
"api.2.body.tags.file_token": "<file_token from upload response>",
}
for path, want := range reportChecks {
if got := clie2e.DryRunGet(out, path).String(); got != want {
t.Fatalf("data.%s = %q, want %q\nstdout:\n%s", path, got, want, out)
}
}
if got := clie2e.DryRunGet(out, "api.3.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("data.api.3.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_ImportWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
workDir := t.TempDir()
fileName := "import-" + clie2e.GenerateSuffix() + ".md"
if err := os.WriteFile(filepath.Join(workDir, fileName), []byte("# lark-cli import e2e\n"), 0o644); err != nil {
t.Fatalf("write import fixture: %v", err)
}
var importedToken string
importedType := "docx"
parentT.Cleanup(func() {
if importedToken == "" {
return
}
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := DeleteDriveResourceAndVerify(cleanupCtx, importedToken, importedType, "bot")
clie2e.ReportCleanupFailure(parentT, "delete imported document "+importedToken, deleteResult, deleteErr)
})
importCtx, importCancel := context.WithTimeout(ctx, 90*time.Second)
result, err := clie2e.RunCmd(importCtx, clie2e.Request{
Args: []string{"drive", "+import", "--file", fileName, "--type", "docx"},
WorkDir: workDir,
DefaultAs: "bot",
})
importCancel()
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
ticket := gjson.Get(result.Stdout, "data.ticket").String()
require.NotEmpty(t, ticket, "import should return a task ticket, stdout:\n%s", result.Stdout)
if got := gjson.Get(result.Stdout, "data.type").String(); got != "" {
importedType = got
}
importedToken = gjson.Get(result.Stdout, "data.token").String()
if importedToken == "" {
importedToken, importedType = waitDriveImportReady(t, ctx, ticket, importedType)
}
require.NotEmpty(t, importedToken, "ready import should return a document token")
for _, reportOnlyField := range []string{"data.file_scene", "data.scene", "data.operation"} {
if gjson.Get(result.Stdout, reportOnlyField).Exists() {
t.Fatalf("report-only field %q leaked into import stdout:\n%s", reportOnlyField, result.Stdout)
}
}
}
// waitDriveImportReady polls an import task until it returns a document token or
// the workflow timeout expires.
func waitDriveImportReady(t *testing.T, ctx context.Context, ticket, fallbackType string) (string, string) {
t.Helper()
deadline := time.NewTimer(90 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+task_result", "--scenario", "import", "--ticket", ticket},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
if gjson.Get(result.Stdout, "data.failed").Bool() {
t.Fatalf("import task failed: %s", result.Stdout)
}
if gjson.Get(result.Stdout, "data.ready").Bool() {
docType := gjson.Get(result.Stdout, "data.type").String()
if docType == "" {
docType = fallbackType
}
return gjson.Get(result.Stdout, "data.token").String(), docType
}
select {
case <-ctx.Done():
t.Fatalf("wait for import task %s: %v", ticket, ctx.Err())
case <-deadline.C:
t.Fatalf("import task %s did not become ready within 90s", ticket)
case <-ticker.C:
}
}
}

View File

@@ -40,6 +40,7 @@ func TestDriveUploadDryRun_WikiTarget(t *testing.T) {
assert.Contains(t, output, "parent_node")
assert.Contains(t, output, "wikcnDryRunUploadTarget")
assert.Contains(t, output, `"parent_type": "wiki"`)
assertDriveUploadReportDryRun(t, result.Stdout, "wiki")
}
func TestDriveUploadDryRun_WithFileToken(t *testing.T) {
@@ -67,6 +68,7 @@ func TestDriveUploadDryRun_WithFileToken(t *testing.T) {
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"parent_node": "fldDryRunUploadTarget"`)
assert.Equal(t, "boxcnDryRunOverwriteTarget", clie2e.DryRunGet(output, "api.0.body.file_token").String())
assertDriveUploadReportDryRun(t, result.Stdout, "explorer")
}
func TestDriveUploadDryRunRejectsEmptyWikiToken(t *testing.T) {
@@ -96,3 +98,32 @@ func setDriveDryRunConfigEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// assertDriveUploadReportDryRun verifies the upload report request in a dry-run
// plan for the expected Drive mount point.
func assertDriveUploadReportDryRun(t *testing.T, out, mountPoint string) {
t.Helper()
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "POST" {
t.Fatalf("data.api.1.method = %q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("data.api.1.url = %q, want report_file_event\nstdout:\n%s", got, out)
}
checks := map[string]string{
"api.1.body.file_scene": "lark-cli",
"api.1.body.scene": "upload",
"api.1.body.operation": "upload",
"api.1.body.tags.api_path": "/open-apis/drive/v1/files/upload_all",
"api.1.body.tags.command": "drive +upload",
"api.1.body.tags.upload_mode": "singlepart",
"api.1.body.tags.resource_type": "file",
"api.1.body.tags.status": "success",
"api.1.body.tags.mount_point": mountPoint,
"api.1.body.tags.file_token": "<file_token from upload response>",
}
for path, want := range checks {
if got := clie2e.DryRunGet(out, path).String(); got != want {
t.Fatalf("data.%s = %q, want %q\nstdout:\n%s", path, got, want, out)
}
}
}

View File

@@ -64,7 +64,10 @@ func TestDrive_UploadWorkflow(t *testing.T) {
args = append(args, "--file-token", fileToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
uploadCtx, uploadCancel := context.WithTimeout(ctx, 45*time.Second)
defer uploadCancel()
result, err := clie2e.RunCmd(uploadCtx, clie2e.Request{
Args: args,
WorkDir: workDir,
DefaultAs: "bot",
@@ -72,6 +75,11 @@ func TestDrive_UploadWorkflow(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, reportOnlyField := range []string{"data.file_scene", "data.scene", "data.operation"} {
if gjson.Get(result.Stdout, reportOnlyField).Exists() {
t.Fatalf("report-only field %q leaked into upload stdout:\n%s", reportOnlyField, result.Stdout)
}
}
gotToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, gotToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)

View File

@@ -17,11 +17,10 @@ import (
// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets
// image-upload shortcuts emit in --dry-run output for native vs. imported
// "office" spreadsheets. For native tokens parent_type must be "sheet_image";
// for tokens prefixed with "fake_office_" (the synthetic token an imported
// office spreadsheet carries) the backend requires "office_sheet_file". The
// three covered entries — sheets +media-upload (backward), sheets
// +cells-set-image, and sheets +create-float-image — are every image-upload
// surface that the office/native split fans out to.
// for tokens carrying the interleaved "OFL0X" marker the backend requires
// "office_sheet_file". The covered entries — sheets +media-upload (backward),
// sheets +cells-set-image, and sheets +float-image-create — are every
// image-upload surface that the office/native split fans out to.
func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
setSheetsDryRunEnv(t)
@@ -50,11 +49,11 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
name: "media-upload office",
args: []string{
"sheets", "+media-upload",
"--spreadsheet-token", "fake_office_dryrun",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run",
},
token: "fake_office_dryrun",
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
{
@@ -74,13 +73,30 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
name: "cells-set-image office",
args: []string{
"sheets", "+cells-set-image",
"--spreadsheet-token", "fake_office_dryrun",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--sheet-id", "sheet1",
"--range", "A1",
"--image", "img.png",
"--dry-run",
},
token: "fake_office_dryrun",
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
{
name: "float-image-create office",
args: []string{
"sheets", "+float-image-create",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--sheet-id", "sheet1",
"--image-name", "img.png",
"--image", "img.png",
"--position-row", "0",
"--position-col", "A",
"--size-width", "100",
"--size-height", "100",
"--dry-run",
},
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
}

View File

@@ -14,12 +14,11 @@ import (
"github.com/stretchr/testify/require"
)
// TestSheets_WorkbookImportDryRun pins the +workbook-import dry-run shape: a
// two-step plan that uploads the local file (drive media upload) and creates
// an import task with the doc type pinned to "sheet". This is the new shortcut
// added in this branch — distinct from generic drive +import because it
// hard-codes type=sheet and uses --name instead of --file-name. AGENTS.md
// requires a dry-run E2E to lock the request shape before a live run.
// TestSheets_WorkbookImportDryRun pins the +workbook-import dry-run shape:
// upload the local file, best-effort report the upload, create an import task,
// and poll it with the doc type pinned to "sheet". The shortcut is distinct
// from generic drive +import because it hard-codes type=sheet and uses --name
// instead of --file-name.
func TestSheets_WorkbookImportDryRun(t *testing.T) {
setSheetsDryRunEnv(t)
@@ -56,17 +55,28 @@ func TestSheets_WorkbookImportDryRun(t *testing.T) {
require.Equal(t, "ccm_import_open", clie2e.DryRunGet(out, "api.0.body.parent_type").String(),
"stdout:\n%s", out)
// api.1 — create import task. type=sheet is the wrapper's whole reason for
// api.1 — report the completed upload using the workbook-import command
// identity while retaining the shared drive import mount point.
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/drive/v1/lark_cli_file_event/report",
clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "lark-cli", clie2e.DryRunGet(out, "api.1.body.file_scene").String(), "stdout:\n%s", out)
require.Equal(t, "sheets +workbook-import", clie2e.DryRunGet(out, "api.1.body.tags.command").String(),
"stdout:\n%s", out)
require.Equal(t, "ccm_import_open", clie2e.DryRunGet(out, "api.1.body.tags.mount_point").String(),
"stdout:\n%s", out)
// api.2 — create import task. type=sheet is the wrapper's whole reason for
// existing (drive +import would require --doc-type sheet explicitly);
// --name reaches the wire as file_name; file_extension is sniffed from
// the local file (.csv).
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/drive/v1/import_tasks",
clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "sheet", clie2e.DryRunGet(out, "api.1.body.type").String(),
clie2e.DryRunGet(out, "api.2.url").String(), "stdout:\n%s", out)
require.Equal(t, "sheet", clie2e.DryRunGet(out, "api.2.body.type").String(),
"workbook-import must hard-code type=sheet; stdout:\n%s", out)
require.Equal(t, "imported", clie2e.DryRunGet(out, "api.1.body.file_name").String(),
require.Equal(t, "imported", clie2e.DryRunGet(out, "api.2.body.file_name").String(),
"--name should reach file_name; stdout:\n%s", out)
require.Equal(t, "csv", clie2e.DryRunGet(out, "api.1.body.file_extension").String(),
require.Equal(t, "csv", clie2e.DryRunGet(out, "api.2.body.file_extension").String(),
"file_extension sniffed from .csv; stdout:\n%s", out)
}