mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
13 Commits
v1.0.78-be
...
feat/uploa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b573a3fe01 | ||
|
|
7846b89754 | ||
|
|
89138261fd | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
261
shortcuts/common/lark_cli_file_event.go
Normal file
261
shortcuts/common/lark_cli_file_event.go
Normal 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()
|
||||
}
|
||||
369
shortcuts/common/lark_cli_file_event_test.go
Normal file
369
shortcuts/common/lark_cli_file_event_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 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 命令)
|
||||
|
||||
@@ -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` 对应的重复范围。
|
||||
|
||||
@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
@@ -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 的多页大改,不新建链接 |
|
||||
|
||||
@@ -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]; 作为后代段落的初始计数器, 子元素 <p>/<ol> 可通过自身 autoStartAt 重置
|
||||
- bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
|
||||
- anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
|
||||
- autoFit: 控制文本编辑溢出时处理策略
|
||||
- baseline: 上标/下标, 相较于文本基线的偏移量
|
||||
@@ -1049,6 +1170,13 @@
|
||||
|
||||
注意:如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值
|
||||
|
||||
autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
|
||||
<content autoStartAt="5">
|
||||
<p list="number">A</p> <!-- A=5, 继承 content 初始值 -->
|
||||
<p list="number" autoStartAt="10">B</p> <!-- B=10, 本段显式重置 -->
|
||||
<p list="number">C</p> <!-- C=11, 沿用前序计数器递增 -->
|
||||
</content>
|
||||
|
||||
子元素:
|
||||
- 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]; 作用于本列表组的计数器初始值, 子元素 <li@index> 可覆盖单项编号
|
||||
</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>
|
||||
通用公式元素。
|
||||
用于展示各类科学公式。
|
||||
|
||||
结构说明:
|
||||
- 必须从支持的公式格式中选择且仅选择一种作为子元素。
|
||||
- 当前版本支持格式:<latex>
|
||||
|
||||
示例:
|
||||
- 基础公式:
|
||||
<formula>
|
||||
<latex><![CDATA[ E = mc^2 ]]></latex>
|
||||
</formula>
|
||||
</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 转义(如 &lt;, &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>1.2.3.- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation>1 2 3 - 全角纯数字</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"/>
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
108
tests/cli_e2e/drive/drive_import_workflow_test.go
Normal file
108
tests/cli_e2e/drive/drive_import_workflow_test.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user