mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
42 Commits
v1.0.77
...
sun/lark-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a79483ac5 | ||
|
|
cd8db34f83 | ||
|
|
e1c5ade76e | ||
|
|
26d8f16fa0 | ||
|
|
48936606c7 | ||
|
|
43825e15ed | ||
|
|
0929b3b8ff | ||
|
|
eb4bae573d | ||
|
|
d08af40faf | ||
|
|
c015d15d60 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 | ||
|
|
38e8806d91 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -2,6 +2,61 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [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
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
@@ -1667,6 +1722,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[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
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
|
||||
@@ -627,7 +627,7 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
||||
authErr.Hint = "existing hint"
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create, docs:document.media:upload, docx:document:write_only, docx:document:readonly"
|
||||
if authErr.Hint != want {
|
||||
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.80",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
if grep -Fq 'run.name !== "CI"' "$workflow"; then
|
||||
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
|
||||
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
|
||||
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
|
||||
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
|
||||
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
|
||||
|
||||
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
|
||||
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
|
||||
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
|
||||
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
|
||||
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
"total": 2,
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||
if !strings.Contains(got, `"visible_rule"`) {
|
||||
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
t.Fatalf("expected error for invalid questions JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
if rule["logic"] != "and" {
|
||||
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||
|
||||
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Update uses full question overwrite semantics, not a patch.",
|
||||
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question create visible_rule",
|
||||
shortcut: BaseFormQuestionsCreate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question update visible_rule",
|
||||
shortcut: BaseFormQuestionsUpdate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"Update uses full question overwrite semantics",
|
||||
"run +form-questions-list first",
|
||||
"include existing values you want to keep",
|
||||
"pass null or omit to clear",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
"full question overwrite semantics, not a patch",
|
||||
"Run +form-questions-list first",
|
||||
"title/description/required/option_display_mode/visible_rule",
|
||||
"Omitted fields reset to defaults",
|
||||
"empty strings, null, and empty arrays are written as empty/clear",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
collapseDescription(e)
|
||||
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
|
||||
eventData := map[string]interface{}{
|
||||
"summary": runtime.Str("summary"),
|
||||
"description": runtime.Str("description"),
|
||||
"start_time": map[string]string{"timestamp": startTs},
|
||||
"end_time": map[string]string{"timestamp": endTs},
|
||||
"attendee_ability": "can_modify_event",
|
||||
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
if rrule := runtime.Str("rrule"); rrule != "" {
|
||||
eventData["recurrence"] = rrule
|
||||
}
|
||||
if description := descriptionToSend(runtime); description != "" {
|
||||
eventData["description_rich"] = description
|
||||
}
|
||||
return eventData
|
||||
}
|
||||
|
||||
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
||||
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ type calendarEvent struct {
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DescriptionRich string `json:"description_rich,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
collapseDescription(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured patch body: %v", err)
|
||||
}
|
||||
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
|
||||
// --description is the unified field, treated as rich text and sent as
|
||||
// description_rich; the CLI never sends the plain description field
|
||||
// (mutually exclusive downstream).
|
||||
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
|
||||
t.Fatalf("unexpected patch body: %#v", body)
|
||||
}
|
||||
if _, ok := body["description"]; ok {
|
||||
t.Fatalf("plain description must not be sent, got: %#v", body)
|
||||
}
|
||||
if body["need_notification"] != false {
|
||||
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
|
||||
}
|
||||
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/events/instance_view",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"event_id": "evt_rich",
|
||||
"summary": "Rich",
|
||||
"status": "confirmed",
|
||||
"description": "[测试]\n友情提醒",
|
||||
"description_rich": "友情提醒",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"event_id": "evt_plain",
|
||||
"summary": "Plain",
|
||||
"status": "confirmed",
|
||||
"description": "just text",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarAgenda, []string{
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Read exposes a single unified description field: it carries the rich
|
||||
// (Markdown) value when present, and the plain text otherwise. The internal
|
||||
// description_rich key is never surfaced.
|
||||
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_EmptyResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
// Read exposes a single unified description field carrying the rich value
|
||||
// when present, and the plain text otherwise; description_rich is dropped.
|
||||
t.Run("rich present", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_rich",
|
||||
"summary": "Rich",
|
||||
"description": "[表格]",
|
||||
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "| a | b |") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
// When only a plain description exists, it is surfaced under description.
|
||||
t.Run("only plain surfaces under description", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_plain",
|
||||
"summary": "Plain",
|
||||
"description": "just text",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description surfaced, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
|
||||
{Name: "event-id", Desc: "event ID to update", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
|
||||
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
body := map[string]interface{}{}
|
||||
hasFields := false
|
||||
|
||||
for _, field := range []string{"summary", "description"} {
|
||||
if runtime.Cmd.Flags().Changed(field) {
|
||||
body[field] = runtime.Str(field)
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("summary") {
|
||||
body["summary"] = runtime.Str("summary")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
body["description_rich"] = runtime.Str("description")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("rrule") {
|
||||
rrule := strings.TrimSpace(runtime.Str("rrule"))
|
||||
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
}
|
||||
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
||||
if summary, _ := event["summary"].(string); summary != "" {
|
||||
result["summary"] = summary
|
||||
}
|
||||
if description, _ := event["description"].(string); description != "" {
|
||||
result["description"] = description
|
||||
if rich, _ := event["description_rich"].(string); rich != "" {
|
||||
result["description"] = rich
|
||||
} else if plain, _ := event["description"].(string); plain != "" {
|
||||
result["description"] = plain
|
||||
}
|
||||
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||
result["start"] = start
|
||||
|
||||
172
shortcuts/calendar/description_rich_images.go
Normal file
172
shortcuts/calendar/description_rich_images.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
// Register the common image decoders so DecodeConfig can read intrinsic
|
||||
// dimensions for PNG/JPEG/GIF sources.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const calendarMediaParentType = "calendar"
|
||||
|
||||
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
|
||||
|
||||
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description")
|
||||
if md == "" || !strings.Contains(md, "![") {
|
||||
return nil
|
||||
}
|
||||
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
|
||||
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
|
||||
if len(matches) == 0 {
|
||||
return md, false, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
last := 0
|
||||
changed := false
|
||||
cache := map[string]string{}
|
||||
for _, m := range matches {
|
||||
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
|
||||
src := strings.TrimSpace(md[srcStart:srcEnd])
|
||||
if !isLocalImageSrc(src) {
|
||||
continue
|
||||
}
|
||||
alt := md[altStart:altEnd]
|
||||
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
out.WriteString(md[last:srcStart])
|
||||
out.WriteString(uploadedURL)
|
||||
last = srcEnd
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return md, false, nil
|
||||
}
|
||||
out.WriteString(md[last:])
|
||||
return out.String(), true, nil
|
||||
}
|
||||
|
||||
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
|
||||
localPath := localImagePath(src)
|
||||
if cached, ok := cache[localPath]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeInputPath(localPath)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description image %q could not be read: %v", src, err).
|
||||
WithParam("--description").
|
||||
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(localPath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
|
||||
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: localPath,
|
||||
FileName: filepath.Base(safePath),
|
||||
FileSize: info.Size(),
|
||||
ParentType: calendarMediaParentType,
|
||||
ParentNode: &calendarID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
width, height := decodeImageDimensions(runtime, localPath)
|
||||
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
|
||||
cache[localPath] = uploadedURL
|
||||
return uploadedURL, nil
|
||||
}
|
||||
|
||||
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
func isLocalImageSrc(src string) bool {
|
||||
if src == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(src)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
|
||||
return false
|
||||
case strings.HasPrefix(lower, "file://"):
|
||||
return true
|
||||
}
|
||||
if i := strings.Index(src, "://"); i > 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func localImagePath(src string) string {
|
||||
s := strings.TrimSpace(src)
|
||||
if strings.HasPrefix(strings.ToLower(s), "file://") {
|
||||
if u, err := url.Parse(s); err == nil && u.Path != "" {
|
||||
s = u.Path
|
||||
}
|
||||
}
|
||||
if decoded, err := url.PathUnescape(s); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
|
||||
host := "internal-api-drive-stream.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "internal-api-drive-stream.larksuite.com"
|
||||
}
|
||||
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
|
||||
if width > 0 && height > 0 {
|
||||
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
|
||||
}
|
||||
if size > 0 {
|
||||
u += fmt.Sprintf("&im_size=%d", size)
|
||||
}
|
||||
return u
|
||||
}
|
||||
279
shortcuts/calendar/description_rich_images_test.go
Normal file
279
shortcuts/calendar/description_rich_images_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestIsLocalImageSrc(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
want bool
|
||||
}{
|
||||
{"./images/pic.png", true},
|
||||
{"images/pic.png", true},
|
||||
{"../assets/a.png", true},
|
||||
{"/Users/me/Desktop/a.png", true},
|
||||
{`C:\Users\me\a.png`, true},
|
||||
{"file:///Users/me/a.png", true},
|
||||
{"图片和附件/测试图片.png", true},
|
||||
{"https://example.com/a.png", false},
|
||||
{"http://example.com/a.png", false},
|
||||
{"HTTPS://EXAMPLE.com/a.png", false},
|
||||
{"data:image/png;base64,iVBOR", false},
|
||||
{"ftp://host/a.png", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isLocalImageSrc(c.src); got != c.want {
|
||||
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalImagePath(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"images/pic.png", "images/pic.png"},
|
||||
{"images/my%20pic.png", "images/my pic.png"},
|
||||
{"file:///Users/me/a.png", "/Users/me/a.png"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := localImagePath(c.in); got != c.want {
|
||||
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
|
||||
// relies on: a Lark host (so token extraction triggers) whose final path
|
||||
// segment is exactly the uploaded file token.
|
||||
func TestBuildCalendarImagePreviewURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
brand core.LarkBrand
|
||||
hostFrag string
|
||||
}{
|
||||
{core.BrandFeishu, "feishu.cn"},
|
||||
{core.BrandLark, "larksuite"},
|
||||
} {
|
||||
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("built URL not parseable: %v", err)
|
||||
}
|
||||
if !strings.Contains(u.Host, tc.hostFrag) {
|
||||
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
|
||||
}
|
||||
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
|
||||
t.Errorf("last path segment = %q, want token", last)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
|
||||
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
|
||||
}
|
||||
}
|
||||
|
||||
// With unknown dimensions the helper params are omitted entirely.
|
||||
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
|
||||
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
|
||||
t.Errorf("expected no dimension params for unknown size, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
|
||||
// pass through unchanged and never trigger an upload (runtime unused → nil).
|
||||
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
|
||||
md := "text  more "
|
||||
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Errorf("changed = true, want false")
|
||||
}
|
||||
if got != md {
|
||||
t.Errorf("markdown mutated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
|
||||
// mocks the drive upload, and asserts the create body's description_rich carries
|
||||
// the uploaded token (not the local path).
|
||||
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(orig)
|
||||
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Pic",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
t.Fatalf("unexpected error: %v", runErr)
|
||||
}
|
||||
|
||||
if uploadStub.CapturedBody == nil {
|
||||
t.Fatalf("expected drive upload to be called")
|
||||
}
|
||||
if createStub.CapturedBody == nil {
|
||||
t.Fatalf("expected create event to be called")
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("create body unmarshal: %v", err)
|
||||
}
|
||||
dr, _ := body["description_rich"].(string)
|
||||
if !strings.Contains(dr, "boxcnTOKEN123") {
|
||||
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
|
||||
}
|
||||
if strings.Contains(dr, "./pic.png") {
|
||||
t.Fatalf("local path should be rewritten away, got %q", dr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
|
||||
// intrinsic width/height and byte size are appended to the rewritten drive URL
|
||||
// (so the facade can populate originalWidth/originalHeight and the client can
|
||||
// render the image inline).
|
||||
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(orig)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Pic",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
t.Fatalf("unexpected error: %v", runErr)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("create body unmarshal: %v", err)
|
||||
}
|
||||
dr, _ := body["description_rich"].(string)
|
||||
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
|
||||
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
|
||||
}
|
||||
if !strings.Contains(dr, "im_size=") {
|
||||
t.Fatalf("description_rich should carry image byte size, got %q", dr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
|
||||
// yields a typed --description validation error before any API call.
|
||||
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr == nil {
|
||||
t.Fatalf("expected error for absolute image path")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(runErr, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
|
||||
}
|
||||
if ve.Param != "--description" {
|
||||
t.Errorf("param = %q, want --description", ve.Param)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
|
||||
return startInput, endInput
|
||||
}
|
||||
|
||||
func collapseDescription(event map[string]interface{}) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
rich, _ := event["description_rich"].(string)
|
||||
plain, _ := event["description"].(string)
|
||||
delete(event, "description_rich")
|
||||
switch {
|
||||
case rich != "":
|
||||
event["description"] = rich
|
||||
case plain != "":
|
||||
event["description"] = plain
|
||||
default:
|
||||
delete(event, "description")
|
||||
}
|
||||
}
|
||||
func descriptionToSend(runtime *common.RuntimeContext) string {
|
||||
return runtime.Str("description")
|
||||
}
|
||||
|
||||
func hasExplicitBotFlag(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
|
||||
@@ -5,9 +5,11 @@ package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -50,6 +52,9 @@ type DriveMediaMultipartUploadConfig struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
Extra string
|
||||
// MinRequestInterval is an optional caller-owned pacing interval between
|
||||
// prepare, part, and finish requests for APIs that disallow concurrency.
|
||||
MinRequestInterval time.Duration
|
||||
// Reader mirrors DriveMediaUploadAllConfig.Reader for chunked uploads.
|
||||
Reader io.Reader
|
||||
}
|
||||
@@ -128,14 +133,34 @@ func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMulti
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
|
||||
if err := waitDriveMediaMultipartRequest(runtime.Ctx(), cfg.MinRequestInterval); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := waitDriveMediaMultipartRequest(runtime.Ctx(), cfg.MinRequestInterval); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
|
||||
}
|
||||
|
||||
func waitDriveMediaMultipartRequest(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
|
||||
// message so callers see which upload step failed. Non-typed errors are
|
||||
// returned unchanged.
|
||||
@@ -206,6 +231,11 @@ func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMedia
|
||||
// Follow the server-declared block plan exactly; upload_finish expects the
|
||||
// same block count returned by upload_prepare.
|
||||
for seq := 0; seq < session.BlockNum; seq++ {
|
||||
if seq > 0 {
|
||||
if err := waitDriveMediaMultipartRequest(runtime.Ctx(), cfg.MinRequestInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
chunkSize := session.BlockSize
|
||||
if remaining > 0 && chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
|
||||
@@ -531,7 +531,7 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
|
||||
case "docx":
|
||||
return docRef.Token, nil
|
||||
case "doc":
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
case "wiki":
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
@@ -551,13 +551,13 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType != "docx" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but this document operation only supports docx documents", objType).WithParam("--doc")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken))
|
||||
return objToken, nil
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents").WithParam("--doc")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -138,6 +139,9 @@ type UploadDocMediaFileConfig struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
DocID string
|
||||
// MinRequestInterval serializes the prepare/part/finish requests of a
|
||||
// multipart upload. Zero preserves the generic uploader's existing behavior.
|
||||
MinRequestInterval time.Duration
|
||||
}
|
||||
|
||||
func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileConfig) (string, error) {
|
||||
@@ -164,13 +168,14 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileCo
|
||||
})
|
||||
}
|
||||
return common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: cfg.FilePath,
|
||||
Reader: cfg.Reader,
|
||||
FileName: cfg.FileName,
|
||||
FileSize: cfg.FileSize,
|
||||
ParentType: cfg.ParentType,
|
||||
ParentNode: cfg.ParentNode,
|
||||
Extra: extra,
|
||||
FilePath: cfg.FilePath,
|
||||
Reader: cfg.Reader,
|
||||
FileName: cfg.FileName,
|
||||
FileSize: cfg.FileSize,
|
||||
ParentType: cfg.ParentType,
|
||||
ParentNode: cfg.ParentNode,
|
||||
Extra: extra,
|
||||
MinRequestInterval: cfg.MinRequestInterval,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,21 @@ func v1CreateFlags() []common.Flag {
|
||||
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
|
||||
}
|
||||
|
||||
var docsCreateLocalResourceScopes = []string{
|
||||
"docs:document.media:upload",
|
||||
"docx:document:write_only",
|
||||
"docx:document:readonly",
|
||||
}
|
||||
|
||||
var DocsCreate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+create",
|
||||
Description: "Create a Lark document",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
PostMount: installDocsShortcutHelp("+create"),
|
||||
Service: "docs",
|
||||
Command: "+create",
|
||||
Description: "Create a Lark document",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
ConditionalScopes: docsCreateLocalResourceScopes,
|
||||
PostMount: installDocsShortcutHelp("+create"),
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
docsAPIVersionCompatFlag(),
|
||||
|
||||
@@ -46,14 +46,19 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("content") != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(input.LocalResources) > 0 {
|
||||
return runtime.EnsureScopes(docsCreateLocalResourceScopes)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
body, resources, err := buildCreateBodyWithPreparedInput(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
@@ -61,14 +66,15 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
if runtime.IsBot() {
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document."
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents").
|
||||
Desc(desc).
|
||||
Body(body)
|
||||
return appendLocalDocResourcesDryRun(dry, "<created_document_id>", resources)
|
||||
}
|
||||
|
||||
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
body, resources, err := buildCreateBodyWithPreparedInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -80,6 +86,12 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
augmentDocsCreatePermission(runtime, data)
|
||||
fallbackDocsCreateURLV2(runtime, data)
|
||||
if len(resources) > 0 {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if err := finalizeLocalDocResources(runtime, strings.TrimSpace(common.GetString(doc, "document_id")), data, resources); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,14 +14,31 @@ func v1UpdateFlags() []common.Flag {
|
||||
return docsLegacyFlagDefinitions(docsUpdateLegacyFlags())
|
||||
}
|
||||
|
||||
var docsUpdateLocalResourceScopes = []string{
|
||||
"docs:document.media:upload",
|
||||
}
|
||||
|
||||
var docsUpdateWikiLocalResourceScopes = []string{
|
||||
"docs:document.media:upload",
|
||||
"wiki:node:retrieve",
|
||||
}
|
||||
|
||||
func docsUpdateLocalResourceScopesFor(ref documentRef) []string {
|
||||
if ref.Kind == "wiki" {
|
||||
return docsUpdateWikiLocalResourceScopes
|
||||
}
|
||||
return docsUpdateLocalResourceScopes
|
||||
}
|
||||
|
||||
var DocsUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+update",
|
||||
Description: "Update a Lark document",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+update"),
|
||||
Service: "docs",
|
||||
Command: "+update",
|
||||
Description: "Update a Lark document",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
ConditionalScopes: docsUpdateWikiLocalResourceScopes,
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+update"),
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
docsAPIVersionCompatFlag(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -50,7 +51,8 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocsV2Only(runtime, "+update", docsUpdateLegacyFlags()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
|
||||
}
|
||||
cmd := runtime.Str("command")
|
||||
@@ -118,8 +120,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
if content != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(input.LocalResources) > 0 {
|
||||
if err := validateLocalDocResourceUpdateCommand(cmd, input.LocalResources); err != nil {
|
||||
return err
|
||||
}
|
||||
return runtime.EnsureScopes(docsUpdateLocalResourceScopesFor(docRef))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -127,32 +137,50 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
body, resources, err := buildUpdateBodyWithPreparedInput(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
documentID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if len(resources) > 0 && ref.Kind == "wiki" {
|
||||
documentID = "<resolved_docx_token>"
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("Resolve wiki node to its docx document before writing local resources").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
}
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID))
|
||||
dry.PUT(apiPath).
|
||||
Desc("OpenAPI: update document").
|
||||
Body(body).
|
||||
Set("document_id", ref.Token)
|
||||
Set("document_id", documentID)
|
||||
return appendLocalDocResourcesDryRun(dry, documentID, resources)
|
||||
}
|
||||
|
||||
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
body, resources, err := buildUpdateBodyWithPreparedInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
documentID := ref.Token
|
||||
if len(resources) > 0 && ref.Kind == "wiki" {
|
||||
documentID, err = resolveDocxDocumentID(runtime, runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID))
|
||||
|
||||
data, err := doDocAPI(runtime, "PUT", apiPath, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := finalizeLocalDocResources(runtime, documentID, data, resources); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,7 +76,14 @@ func extractDocumentFragment(raw string) string {
|
||||
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id
|
||||
// surfaces for support escalations even when the body omits it.
|
||||
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(method, apiPath, nil, body)
|
||||
data, err := runtime.CallAPITyped(method, apiPath, nil, body)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "document API returned an empty data object")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func docsSceneFromContext(ctx context.Context) string {
|
||||
|
||||
@@ -49,8 +49,9 @@ type html5BlockReferenceEntry struct {
|
||||
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
|
||||
|
||||
type docsV2WriteInput struct {
|
||||
Content string
|
||||
ReferenceMap map[string]interface{}
|
||||
Content string
|
||||
ReferenceMap map[string]interface{}
|
||||
LocalResources []localDocResource
|
||||
}
|
||||
|
||||
type html5BlockAttr struct {
|
||||
@@ -68,27 +69,35 @@ type whiteboardStartTag struct {
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
func buildCreateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
return body, nil
|
||||
return body, nil, nil
|
||||
}
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
body["content"] = buildCreateContentWithBody(runtime, input.Content)
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
return body, input.LocalResources, nil
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body, _, err := buildUpdateBodyWithPreparedInput(runtime)
|
||||
return body, err
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) {
|
||||
body := buildUpdateBody(runtime)
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := validateLocalDocResourceUpdateCommand(runtime.Str("command"), input.LocalResources); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if input.Content != "" {
|
||||
body["content"] = input.Content
|
||||
@@ -96,7 +105,7 @@ func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[s
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
return body, input.LocalResources, nil
|
||||
}
|
||||
|
||||
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
|
||||
@@ -125,7 +134,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
|
||||
content, localResources, err := prepareLocalDocResources(runtime, runtime.Str("doc-format"), input.Content)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
content, err = prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), content)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
@@ -138,8 +151,9 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
||||
}
|
||||
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
|
||||
return docsV2WriteInput{
|
||||
Content: content,
|
||||
ReferenceMap: refMap,
|
||||
Content: content,
|
||||
ReferenceMap: refMap,
|
||||
LocalResources: localResources,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
2192
shortcuts/doc/local_doc_resources.go
Normal file
2192
shortcuts/doc/local_doc_resources.go
Normal file
File diff suppressed because it is too large
Load Diff
1054
shortcuts/doc/local_doc_resources_test.go
Normal file
1054
shortcuts/doc/local_doc_resources_test.go
Normal file
File diff suppressed because it is too large
Load Diff
325
shortcuts/drive/drive_member_list.go
Normal file
325
shortcuts/drive/drive_member_list.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveMemberListSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
Fields string
|
||||
PermType string
|
||||
}
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
var driveMemberListPermTypes = []string{"container", "single_page"}
|
||||
|
||||
var driveMemberListURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
return driveMemberListSpec{
|
||||
Token: token,
|
||||
Type: resourceType,
|
||||
Fields: fields,
|
||||
PermType: permType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return ref.Token, ref.Type, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberListTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(raw, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return raw, explicitType, nil
|
||||
}
|
||||
|
||||
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
|
||||
for _, mapping := range driveMemberListURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
field := strings.ToLower(strings.TrimSpace(part))
|
||||
if field == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
if field == "*" {
|
||||
if len(parts) != 1 {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
|
||||
}
|
||||
return "*", nil
|
||||
}
|
||||
if !driveMemberListFieldAllowed(field) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for --fields, allowed: %s, *",
|
||||
strings.TrimSpace(part),
|
||||
strings.Join(driveMemberListFields, ", "),
|
||||
).WithParam("--fields")
|
||||
}
|
||||
if !seen[field] {
|
||||
fields = append(fields, field)
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, ","), nil
|
||||
}
|
||||
|
||||
func driveMemberListFieldAllowed(field string) bool {
|
||||
for _, allowed := range driveMemberListFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
|
||||
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resourceType != "wiki" && changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
}
|
||||
return permType, nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) apiPath() string {
|
||||
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": s.Type}
|
||||
if s.Fields != "" {
|
||||
params["fields"] = s.Fields
|
||||
}
|
||||
if s.PermType != "" {
|
||||
params["perm_type"] = s.PermType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// DriveMemberList lists collaborator/member permissions on a Drive resource.
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders.",
|
||||
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
|
||||
"--perm-type only applies to wiki nodes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List Drive collaborator/member permissions").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := data["items"].([]interface{}); ok {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderDriveMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No Drive members found.")
|
||||
return
|
||||
}
|
||||
for i, raw := range items {
|
||||
member, _ := raw.(map[string]interface{})
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
|
||||
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
|
||||
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
|
||||
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
|
||||
fmt.Fprintf(w, " perm_type: %s\n", permType)
|
||||
}
|
||||
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
|
||||
fmt.Fprintf(w, " type: %s\n", memberType)
|
||||
}
|
||||
if name := driveMemberListValue(member["name"]); name != "-" {
|
||||
fmt.Fprintf(w, " name: %s\n", name)
|
||||
}
|
||||
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
|
||||
fmt.Fprintf(w, " avatar: %s\n", avatar)
|
||||
}
|
||||
if label, ok := member["external_label"]; ok {
|
||||
fmt.Fprintf(w, " external_label: %v\n", label)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberListValue(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
426
shortcuts/drive/drive_member_list_test.go
Normal file
426
shortcuts/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +member-list"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("fields", "", "")
|
||||
cmd.Flags().String("perm-type", "", "")
|
||||
for name, value := range map[string]string{
|
||||
"token": token,
|
||||
"type": docType,
|
||||
"fields": fields,
|
||||
"perm-type": permType,
|
||||
} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
|
||||
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "folder",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "name,unknown",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "star mixed with fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "*,name",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "perm type rejected for non-wiki",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
permType: "single_page",
|
||||
wantParam: "--perm-type",
|
||||
wantMessage: "only applies when resource type is wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default omits optional params",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
want: map[string]interface{}{"type": "docx"},
|
||||
},
|
||||
{
|
||||
name: "fields canonicalized and deduplicated",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "Name,avatar,name",
|
||||
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
|
||||
},
|
||||
{
|
||||
name: "wiki accepts perm type",
|
||||
token: "wikTok",
|
||||
docType: "WIKI",
|
||||
fields: "*",
|
||||
permType: "SINGLE_PAGE",
|
||||
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("params = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldTok",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
|
||||
t.Fatalf("params = %#v", api.Params)
|
||||
}
|
||||
if _, ok := api.Params["perm_type"]; ok {
|
||||
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
var capturedQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"server_future": "preserved",
|
||||
"external_label": true,
|
||||
},
|
||||
},
|
||||
"server_top_level": "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--fields", "name,type,external_label",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(capturedQuery, "type=docx") ||
|
||||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
|
||||
t.Fatalf("captured query = %q", capturedQuery)
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["server_top_level"] != "preserved" {
|
||||
t.Fatalf("server_top_level = %#v", data["server_top_level"])
|
||||
}
|
||||
for _, key := range []string{"token", "type", "count"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["server_future"] != "preserved" || item["external_label"] != true {
|
||||
t.Fatalf("item future fields not preserved: %#v", item)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
|
||||
t.Fatalf("stderr = %q, want count log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/wikTok/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"perm_type": "single_page",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "wikTok",
|
||||
"--type", "wiki",
|
||||
"--perm-type", "single_page",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type drivePermissionGetSettingSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
rawToken := strings.TrimSpace(runtime.Str("token"))
|
||||
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
|
||||
if rawToken == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token is required",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(rawToken, "://") {
|
||||
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token (allowed: %s)",
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(rawToken, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, mapping := range drivePermissionGetSettingURLPathToType {
|
||||
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := parsed.Path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
for _, allowed := range drivePermissionGetSettingTypes {
|
||||
if docType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return common.BuildResourceURL("", s.Type, s.Token)
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
return map[string]interface{}{"type": s.Type}
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) apiPath() string {
|
||||
return drivePermissionPublicV2Path(s.Token)
|
||||
}
|
||||
|
||||
func drivePermissionPublicV2Path(token string) string {
|
||||
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
permissionPublic := common.GetMap(data, "permission_public")
|
||||
if permissionPublic == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"drive permission get response missing data.permission_public",
|
||||
)
|
||||
}
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive
|
||||
// document, file, wiki node, or folder.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.setting:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Get Drive permission settings").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
spec.apiPath(),
|
||||
spec.params(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"encode drive permission settings for pretty output",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{"permission_public": permissionPublic}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", spec.Type)
|
||||
fmt.Fprintf(w, "Token: %s\n", spec.Token)
|
||||
if url := spec.url(runtime); url != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", url)
|
||||
}
|
||||
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if token != "" {
|
||||
if err := cmd.Flags().Set("token", token); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
}
|
||||
if docType != "" {
|
||||
if err := cmd.Flags().Set("type", docType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "wiki URL",
|
||||
token: "https://example.feishu.cn/wiki/wikTok",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare file token",
|
||||
token: "boxTok",
|
||||
docType: "file",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok {
|
||||
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "sheet",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid --type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr, ok := err.(*errs.ValidationError); ok {
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: "fldTok",
|
||||
docType: "folder",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
out := string(data)
|
||||
for _, want := range []string{
|
||||
`"` + tt.wantURL + `"`,
|
||||
`"GET"`,
|
||||
`"type":"` + tt.wantType + `"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"folder_token"`) {
|
||||
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
for _, key := range []string{"type", "token", "url"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
||||
if permissionPublic == nil {
|
||||
t.Fatalf("permission_public missing in output: %#v", data)
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
} {
|
||||
if permissionPublic[key] != want {
|
||||
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"unexpected": "response"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Permission settings:",
|
||||
`"link_share_entity": "closed"`,
|
||||
`"server_future_field": "preserved"`,
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
||||
}
|
||||
for _, flag := range DrivePermissionGetSetting.Flags {
|
||||
if flag.Name == "token" && !flag.Required {
|
||||
t.Fatal("--token must be declared required")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -57,12 +57,12 @@ metadata:
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)(filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md));其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
@@ -116,6 +116,7 @@ metadata:
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
@@ -146,13 +147,14 @@ metadata:
|
||||
## 保留 Reference
|
||||
|
||||
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT;`+data-query` 的 `filters` 结构是独立对象 DSL,不使用公共 tuple filter 协议
|
||||
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
|
||||
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
|
||||
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
|
||||
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
|
||||
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT;不适用于 `+data-query`
|
||||
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
|
||||
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
|
||||
|
||||
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Base Filter 条件结构(公共协议)
|
||||
|
||||
Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构,本文件是其公共协议(SSOT)。
|
||||
|
||||
## 0. 适用范围
|
||||
|
||||
本协议只适用于以下场景:
|
||||
|
||||
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
|
||||
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
|
||||
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
|
||||
|
||||
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
|
||||
|
||||
## 1. 顶层结构
|
||||
|
||||
- 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["状态", "intersects", ["Doing"]],
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]],
|
||||
["截止时间", "empty"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
清空写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
value 类型取决于条件引用对象(字段 / 题目)的类型。
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 5. 参考
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"type":"text","title":"您的姓名是?","required":true},
|
||||
{"type":"text","title":"您的联系方式是?","required":false}
|
||||
]'
|
||||
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
|
||||
|
||||
# 添加单选题(带选项)
|
||||
lark-cli base +form-questions-create \
|
||||
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
|
||||
|
||||
# 添加带显隐条件(visible_rule)的问题:当「是否需要发票」选择「是」时才显示「发票抬头」
|
||||
lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
|
||||
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效,bool) |
|
||||
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`,hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
|
||||
| `style` | 否 | 字段样式配置(见下方说明) |
|
||||
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
|
||||
|
||||
### `style` 字段说明
|
||||
|
||||
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
|
||||
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
|
||||
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` |
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
|
||||
|
||||
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
|
||||
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
|
||||
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
|
||||
- 引用的题目必须真实存在,否则会报错。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["是否需要发票", "==", "是"],
|
||||
["报销金额", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
返回创建成功的问题列表:
|
||||
@@ -115,4 +144,5 @@ lark-cli base +form-questions-create \
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,40 +2,60 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量更新多维表格表单/问卷中的问题(标题、描述、是否必填)。
|
||||
批量更新多维表格表单/问卷中的问题配置(标题、描述、是否必填、显隐条件等)。
|
||||
|
||||
> [!CAUTION]
|
||||
> `+form-questions-update` 是**题目配置全量覆盖**,不是 patch。对每个传入的题目,未携带的属性会回落为默认值,显式传空字符串 / `null` / 空数组会直接写入空或清空;如果要保留现有属性,必须先用 `+form-questions-list` 查出现状,再把要保留的字段一起带回 `--questions`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 更新一个问题的标题
|
||||
lark-cli base +form-questions-update \
|
||||
# 先读取现有题目配置,作为 read-modify-write 的基线
|
||||
lark-cli base +form-questions-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?"}]'
|
||||
--form-id <form_id>
|
||||
|
||||
# 同时更新多个问题
|
||||
# 更新一个问题的标题,同时带回要保留的 required / description / visible_rule 等字段
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"id":"q_001","title":"姓名(必填)","required":true},
|
||||
{"id":"q_002","title":"联系方式","required":false}
|
||||
]'
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?","description":"请填写真实姓名","required":true,"visible_rule":null}]'
|
||||
|
||||
# 同时更新多个问题;每个对象都应是该题目的目标完整配置
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"姓名(必填)","required":true},{"id":"q_002","title":"联系方式","required":false}]'
|
||||
|
||||
# 更新问题描述(纯文本)
|
||||
# 更新问题描述(纯文本),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"请填写您的真实姓名"}]'
|
||||
# 更新问题描述(含链接)
|
||||
--questions '[{"id":"q_001","title":"您的姓名","description":"请填写您的真实姓名","required":true,"visible_rule":null}]'
|
||||
# 更新问题描述(含链接),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"更多说明请参考[帮助文档](https://example.com/help)"}]'
|
||||
--questions '[{"id":"q_001","title":"反馈建议","description":"更多说明请参考[帮助文档](https://example.com/help)","required":false,"visible_rule":null}]'
|
||||
|
||||
# 更新题目显隐条件(visible_rule),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]'
|
||||
|
||||
# 清空题目显隐条件(使题目始终显示),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":null}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -52,15 +72,46 @@ lark-cli base +form-questions-update \
|
||||
|
||||
## `--questions` 格式
|
||||
|
||||
每个问题对象必须包含 `id`,其余字段按需传入:
|
||||
每个问题对象必须包含 `id`。注意:对象不是增量 patch,而是该题目的目标完整配置;未携带字段会按服务端默认值重建。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | **是** | 问题 ID(field_id),不可修改 |
|
||||
| `title` | 否 | 新的问题标题 |
|
||||
| `description` | 否 | 新的问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`) |
|
||||
| `required` | 否 | 是否必填 |
|
||||
| `option_display_mode` | 否 | 选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向 |
|
||||
| `title` | 否 | 目标问题标题;省略会回落为字段名,传空字符串会写入空标题(若服务端允许) |
|
||||
| `description` | 否 | 目标问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`);省略或传空字符串都会清空描述 |
|
||||
| `required` | 否 | 目标是否必填;省略会回落为 `false` |
|
||||
| `option_display_mode` | 否 | 目标选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向;省略会回落默认展示方式 |
|
||||
| `visible_rule` | 否 | 目标题目显隐条件;传完整 `{logic, conditions}` 对象覆盖,传 `null` 或省略都会清空(见下方说明) |
|
||||
|
||||
## 全量覆盖语义
|
||||
|
||||
- 先执行 `+form-questions-list`,读取被更新题目的当前 `id`、`title`、`description`、`required`、`option_display_mode`、`visible_rule`。
|
||||
- 构造 `--questions` 时,只改用户明确要求变化的字段;所有仍要保留的字段必须按当前值一并传回。
|
||||
- 不要用“只传要改的字段”的方式更新题目。比如只传 `{"id":"q_002","title":"新标题"}` 会让 `description` 清空、`required` 回落为 `false`、`visible_rule` 清空。
|
||||
- 用户明确要求清空时才传空值:`description:""` 清空描述,`visible_rule:null` 清空显隐条件,`conditions:[]` 也表示无条件显示。
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置或修改显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目显示/隐藏,**结构与视图筛选 `filter` 完全一致**(`{logic?, conditions?}`),共用同一套公共协议。
|
||||
|
||||
- `conditions` 中的 `field` 引用**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID)。
|
||||
- 更新时按表单中题目的**实际顺序**判定,只能引用排在当前题目之前的题目;不支持循环引用。
|
||||
- 更新 `visible_rule` 需传**完整**的 `{logic, conditions}` 对象(整体覆盖);要保留现有显隐条件就必须把当前 `visible_rule` 原样带回;传 `null`、省略 `visible_rule` 或传空 `conditions` 都会使题目始终显示。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["q_001", "==", "是"],
|
||||
["q_003", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -82,11 +133,13 @@ lark-cli base +form-questions-update \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id`
|
||||
2. 构造包含 `id` 的更新数组
|
||||
3. 执行命令并报告更新结果
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id` 和完整配置。
|
||||
2. 以现有配置为基线,只修改用户明确要求变化的字段;要保留的字段必须原样带回。
|
||||
3. 构造包含 `id` 和目标完整配置的更新数组。
|
||||
4. 执行命令并报告更新结果。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -4,142 +4,13 @@
|
||||
|
||||
更新视图筛选配置。
|
||||
|
||||
## 1. 顶层规则
|
||||
## 1. filter 结构
|
||||
|
||||
`--json` 就是一个 filter 条件对象,结构见公共协议 SSOT [lark-base-filter-condition.md](lark-base-filter-condition.md),即 `{logic?, conditions?}`。此处 `conditions` 中的 `field` 引用**数据表字段名或字段 id**。
|
||||
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
- 支持 `filter` 的视图类型:`grid`、`kanban`、`gallery`、`calendar`、`gantt`。
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 推荐命令
|
||||
## 2. 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +view-set-filter \
|
||||
@@ -149,7 +20,7 @@ lark-cli base +view-set-filter \
|
||||
--json '{"logic":"and","conditions":[["状态","intersects",["Doing"]],["负责人","intersects",[{"id":"ou_xxx"}]],["截止时间","empty"]]}'
|
||||
```
|
||||
|
||||
## 5. JSON 写法
|
||||
## 3. JSON 写法
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -170,14 +41,16 @@ lark-cli base +view-set-filter \
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 使用建议
|
||||
完整的 operator 列表与各字段类型的 value 写法(`text` / `number` / `select` / `user` / `datetime` / `formula` / `lookup` 等),见 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 4. 使用建议
|
||||
|
||||
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
||||
- 优先传字段 id,不要依赖字段名。
|
||||
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
||||
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
||||
|
||||
## 7. 易错点
|
||||
## 5. 易错点
|
||||
|
||||
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
||||
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
||||
@@ -186,6 +59,7 @@ lark-cli base +view-set-filter \
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前 filter 或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 8. 参考
|
||||
## 6. 参考
|
||||
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md):filter/visible_rule 条件结构公共协议 SSOT
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
|
||||
@@ -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
|
||||
@@ -48,6 +50,8 @@ lark-cli calendar +agenda --as user
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||
@@ -186,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)
|
||||
@@ -195,4 +201,4 @@ lark-cli im +chat-search --query <query> --as user
|
||||
- 会议室物理设施管理 → 管理员后台
|
||||
|
||||
**注意(强制性):**
|
||||
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确。违者将导致严重的逻辑错误!
|
||||
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确;换算**禁止依赖容器默认时区**(常为 UTC,会导致 8 小时偏移),必须显式指定目标时区。违者将导致严重的逻辑错误!
|
||||
|
||||
@@ -30,21 +30,21 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
|
||||
| `--start <time>` | 是 | 开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`) |
|
||||
| `--end <time>` | 是 | 结束时间(ISO 8601) |
|
||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--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 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `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 命令)
|
||||
@@ -61,7 +61,7 @@ lark-cli calendar event.attendees create \
|
||||
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||
|
||||
完整 API 命令的关键差异:
|
||||
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。换算时**禁止依赖容器默认时区**(常为 UTC,会导致 8 小时偏移),必须显式指定目标时区。
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
|
||||
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
|
||||
- 设置会议 owner:`+create` 不支持,需用完整 API 命令在 `vchat.meeting_settings.owner_id` 中设置,且必须同时设置 `vchat.vc_type` 为 `vc`(代表该日程为 VC 视频会议)。仅当以应用(bot)身份在应用日历上操作时生效;owner 必须为用户身份(`ou_` open_id),不能为非用户或外部租户用户。
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -43,9 +43,9 @@ lark-cli calendar +update \
|
||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
||||
| `--start <time>` | 否 | 新开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
|
||||
| `--end <time>` | 否 | 新结束时间(ISO 8601)。更新日程时间时必须同时传 `--start` |
|
||||
| `--description <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径` 或 `-`(stdin)读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。 |
|
||||
| `--start <time>` | 否 | 新开始时间(ISO 8601,**必须带时区偏移**,如 `2026-03-12T14:00+08:00`;不带偏移会按进程时区解析致偏移)。更新日程时间时必须同时传 `--end` |
|
||||
| `--end <time>` | 否 | 新结束时间(ISO 8601,**必须带时区偏移**)。更新日程时间时必须同时传 `--start` |
|
||||
| `--rrule <rrule>` | 否 | 新重复规则(RFC5545)。**不要使用 COUNT;如需限制次数,推算后转为 UNTIL** |
|
||||
| `--add-attendee-ids <id_list>` | 否 | 增量添加参会人/会议室,逗号分隔。支持用户 `ou_`、群组 `oc_`、会议室 `omm_` |
|
||||
| `--remove-attendee-ids <id_list>` | 否 | 增量移除参会人/会议室,逗号分隔。支持用户 `ou_`、群组 `oc_`、会议室 `omm_` |
|
||||
@@ -58,9 +58,12 @@ lark-cli calendar +update \
|
||||
|
||||
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
|
||||
- 对 `--summary`、`--description`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
|
||||
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
- 只想增删参会人或会议室时,不需要同时传 `--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) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
@@ -75,7 +78,7 @@ lark-cli calendar +update \
|
||||
|
||||
如需更新 `location`(地理位置,不含会议室位置)、`visibility`(日程公开范围)、自定义 `reminders`(提醒设置)、自定义 `attendee_ability`(参与人权限)、自定义 `free_busy_status`(日程忙闲状态)、`color`(颜色)、附件、视频会议信息、全天日程,或在新增参会人时配置可选参加状态 等高级参数,请改用完整的 API 命令。建议先通过 `lark-cli schema calendar.events.patch`、`lark-cli schema calendar.event.attendees.create`、`lark-cli schema calendar.event.attendees.batch_delete` 查看完整参数定义。
|
||||
|
||||
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601)。换算时**禁止依赖容器默认时区**(常为 UTC,会导致 8 小时偏移),必须显式指定目标时区。
|
||||
|
||||
## 预约/改约会议室场景
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
# 创建 XML 文档(默认格式,推荐)
|
||||
lark-cli docs +create --content '<title>项目计划</title><h1>目标</h1><p>记录本周重点。</p>'
|
||||
|
||||
# 正文中直接插入当前目录内的本地图片和附件
|
||||
lark-cli docs +create --content '<title>周报</title><img path="@images/chart.png"/><source path="@files/report.pdf"/>'
|
||||
|
||||
# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织
|
||||
lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
|
||||
```
|
||||
@@ -41,6 +44,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
```
|
||||
|
||||
- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update` 的 `--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token,可交给 `lark-whiteboard` 等 skill 继续操作
|
||||
- 正文包含 `<img path="@relative">`、`<source path="@relative">` 或 Markdown `` 时,CLI 会在创建文档后自动上传本地资源并回填 token;路径只允许位于当前工作目录内。全部成功时输出结构不变,`new_blocks[].block_token` 已替换为真实媒体 token;部分失败时返回 `ok:false` 和逐项 `summary/items`,但不会回滚正文或已成功资源。
|
||||
|
||||
> \[!IMPORTANT]
|
||||
> 如果文档是**以应用身份(bot)创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
|
||||
|
||||
@@ -66,6 +66,19 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
|
||||
- URL 支持 `http://` 和 `https://` 协议
|
||||
- 对应的 XML 格式为:`<img href="https://example.com/photo.png"/>`
|
||||
|
||||
也支持直接引用当前工作目录内的本地图片:
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
- 路径必须以 `@` 开头,并且是当前工作目录内的相对路径;绝对路径、目录穿越、逃逸到目录外的符号链接、目录和空文件都会在写文档前被拒绝。
|
||||
- `![alt]` 的描述会作为图片 caption 落盘,后续导出 Markdown 时仍会恢复为图片 alt。
|
||||
- 代码围栏、行内代码、四空格/Tab 缩进代码、HTML/XML 注释和 CDATA 中的图片或附件语法不会被处理。
|
||||
- 本地图片暂不支持引用式写法(如 `![alt][ref]` + `[ref]: @image.png`);请改用上面的行内写法。
|
||||
- 本地附件没有 Markdown 原生简写;使用 `<source path="@files/report.pdf"/>`。
|
||||
- 在 `docs +update` 中,本地图片和附件只允许配合 `append` 或 `block_insert_after`,其他写入指令会在 API 调用前被拒绝。
|
||||
- CLI 不会把本地路径发送给文档服务。写入成功后返回的 `document.new_blocks[].block_token` 是真实媒体 token;如果部分资源失败,正文和已成功资源会保留,失败占位会尽力清理并通过结构化 `summary/items` 报告。
|
||||
|
||||
## Markdown 不支持的 Block 类型
|
||||
|
||||
非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
|
||||
### str_replace — 全文文本替换
|
||||
|
||||
> 本地图片和附件只允许用于 `append` 或 `block_insert_after`。`str_replace` 不会创建资源 block,而 `block_replace` / `overwrite` 一旦在后续上传绑定失败会先破坏旧内容,因此 CLI 会在写文档前拒绝这些组合。
|
||||
|
||||
> **匹配范围:**
|
||||
> - **XML 模式(默认)**:`--pattern` 只支持**行内匹配**,不能跨 block / 跨段落匹配。涉及整段或多 block 的改动,请改用 `block_replace`。
|
||||
> - **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行匹配**,可以用多行字符串匹配并替换一整段内容。
|
||||
@@ -144,6 +146,10 @@ lark-cli docs +update --doc "<doc_id>" --command overwrite \
|
||||
```bash
|
||||
lark-cli docs +update --doc "<doc_id>" --command append \
|
||||
--content '<h2>新增章节</h2><p>追加的内容</p>'
|
||||
|
||||
# 追加当前目录内的本地图片和附件;wiki URL 会先解析为实际 docx token
|
||||
lark-cli docs +update --doc "<doc_id或wiki_url>" --command append \
|
||||
--content '<img path="@images/chart.png"/><source path="@files/report.pdf"/>'
|
||||
```
|
||||
|
||||
> 等价于 `block_insert_after --block-id -1`,无需先获取 block ID。
|
||||
@@ -197,6 +203,8 @@ lark-cli docs +update --doc "<doc_id>" --command block_move_after \
|
||||
| `warnings` | 警告信息列表 |
|
||||
| `document.new_blocks` | 本次操作新增的 block 列表(如画板)。`block_id` 可用于后续精确编辑;`block_token` 是资源块 token(如画板)可交给 `lark-whiteboard` 等 skill 继续操作 |
|
||||
|
||||
仅 `append` / `block_insert_after` 可写入本地图片或附件。CLI 会使用本次 `new_blocks` 中的占位标记严格关联 block,完成上传和 token 回填;wiki URL 会先通过 `wiki:node:retrieve` 解析为实际 docx token,再执行写入、上传和绑定。路径不会发送到服务端;全部成功时仍使用上面的既有输出结构,部分失败时增加结构化 `summary/items`,保留正文和已经成功的资源,并清理确认仍为空的失败占位。
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 精确 block 级更新
|
||||
@@ -241,7 +249,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
- **XML 模式(默认)**:`--pattern` 只支持**行内**匹配,不支持跨行 / 跨 block。段落、整块或容器级(列表、表格、分栏、引用块等)改动请改用 `block_replace` 指定 block_id 重建。
|
||||
- **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行**匹配,还支持 `前缀...后缀` 省略号语法(用 `...` 串联首尾片段匹配一大段内容),可以一次替换多行文本;但仍建议优先按最小片段匹配,跨 block 容器级重写仍优先用 `block_replace`,避免副作用。
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **str_replace 的 replacement 支持行内富文本**:可以用 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本,但不支持需要新建 block 的本地图片或附件
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
|
||||
@@ -26,8 +26,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
| `<cite type="user">` | @人 | XML 导入时必须显式传入 `user-id`:`<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
|
||||
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
|
||||
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
|
||||
| `<source>` | 文件附件(可独立成块或内联) | `<source name="报告.pdf"/>` |
|
||||
| `<img>` | 图片(可独立成块或内联) | 网络图片:`<img href="https://..."/>`;当前目录内本地图片:`<img path="@images/a.png"/>` |
|
||||
| `<source>` | 文件附件(可独立成块或内联) | 当前目录内本地文件:`<source path="@files/report.pdf" name="报告.pdf"/>` |
|
||||
| `<a type="url-preview">` | 预览卡片 | `<a type="url-preview" href="...">标题</a>` |
|
||||
| `<button>` | 操作按钮 | `background-color`、`src`,必须包含 `action=OpenLink\|DuplicatePage\|FollowPage` |
|
||||
| `<time>` | 提醒 | 必包含 `expire-time`、`notify-time`(毫秒时间戳)、`should-notify=true\|false` |
|
||||
@@ -41,6 +41,9 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<img path="@relative/path.png" caption="说明"/>` — 在 `docs +create`,或 `docs +update --command append/block_insert_after` 中直接插入本地图片;`path` 必须是当前工作目录内的相对路径,不能与 `src` / `href` / `token` / `img_key` 同时使用。CLI 会先创建占位 block,再上传并回填真实 token;同一文件出现多次会分别上传、分别挂载。兼容旧写法 `alt="说明"`:未显式提供 `caption` 时 CLI 会将 `alt` 映射为 caption。
|
||||
- `<source path="@relative/report.pdf" name="自定义文件名.pdf"/>` — 直接插入本地附件;路径与来源互斥规则同本地图片。`name` 可选,提供时会作为上传后的附件名;附件没有额外的 Markdown 简写,应在 XML 或 Markdown 正文中使用这个原始 XML 标签。
|
||||
- XML/HTML 注释与 CDATA 中的 `<img path>` / `<source path>` 仅作为字面内容,不会触发本地文件读取或上传。
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`、`<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>`、`<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`,CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
@@ -167,8 +170,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
<hr/>
|
||||
|
||||
<source name="文件名.pdf"/>
|
||||
<source path="@files/报告.pdf" name="报告.pdf"/>
|
||||
<img src="IMG_TOKEN" width="800" height="400" caption="说明" name="图.png"/>
|
||||
<img href="https://example.com/photo.png"/>
|
||||
<img path="@images/photo.png" width="800" align="center" caption="说明"/>
|
||||
|
||||
<button action="OpenLink" src="https://example.com">按钮文字</button>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -27,6 +27,7 @@ metadata:
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +permission-get-setting`;它只读取目标自身设置,不递归审计文件夹子文档权限。裸 token 必须显式传 `--type`。
|
||||
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
@@ -120,6 +121,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
### 权限能力入口
|
||||
|
||||
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch` 的 `91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
|
||||
- 用户要查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置,使用 [`+permission-get-setting`](references/lark-drive-permission-get-setting.md);如果要递归审计文件夹下子文档权限,再进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
|
||||
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
|
||||
|
||||
@@ -163,6 +165,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
|
||||
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 |
|
||||
| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 |
|
||||
| [`+permission-get-setting`](references/lark-drive-permission-get-setting.md) | 查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置;支持 URL 或裸 token + `--type`;不递归读取文件夹子文档权限。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
65
skills/lark-drive/references/lark-drive-member-list.md
Normal file
65
skills/lark-drive/references/lark-drive-member-list.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# drive +member-list(查询协作者/授权成员列表)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# URL 自动推断 type
|
||||
lark-cli drive +member-list \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 查询附加字段
|
||||
lark-cli drive +member-list \
|
||||
--token '<token>' \
|
||||
--type docx \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 |
|
||||
| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出原样透传 API 的 `data` :
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"member_type": "openid",
|
||||
"member_id": "ou_xxx",
|
||||
"perm": "view",
|
||||
"perm_type": "container",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"external_label": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。
|
||||
- **接口 scope**:查询成员列表需要 `docs:permission.member:retrieve`。
|
||||
- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。
|
||||
- **字段级权限**:`--fields` 只控制请求哪些附加字段,不保证服务端一定返回。请求用户的 `name` / `avatar` 时,应用还需开通 `contact:user.base:readonly`(“获取用户基本信息”;已具备官方兼容的历史通讯录权限也可满足要求)。
|
||||
- **缺字段语义**:字段级权限或数据可见性不足时,接口仍可能成功,但会省略相应敏感字段。响应中缺少已请求字段表示“服务端未返回”,不能解释为字段值为空,也不能据此认定成员信息完整。
|
||||
- **folder 支持**:CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`。
|
||||
@@ -0,0 +1,48 @@
|
||||
# drive +permission-get-setting(查询权限设置)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +permission-get-setting`。它读取单个 Drive 资源自身的公开访问、分享、协作者管理、安全与评论权限设置,不递归读取文件夹中的子资源。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 URL 自动推断 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 通过 bare token 显式指定 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' \
|
||||
--type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出中的 `data.permission_public` 是目标当前的权限设置;服务端未返回该字段时,命令会报响应结构错误,而不会把其他字段伪装成权限设置。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"permission_public": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会展示完整的 `permission_public` 对象,包括服务端将来新增的字段。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用。
|
||||
- **所需 scope**:`docs:permission.setting:read`。
|
||||
- **单目标读取**:命令只读取 `--token` 指向资源自身的权限设置;`--type folder` 不会递归读取子资源。
|
||||
@@ -26,11 +26,16 @@
|
||||
> **`--query` 最长 30 个字符**:按字符数(Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`,**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
|
||||
>
|
||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||
>
|
||||
> **标题词 + 正文词联合搜索**:如果用户同时给出标题关键词和正文关键词,并要求同一资源同时满足两项条件,优先执行一条普通联合搜索:`lark-cli drive +search --query "标题词 正文词"`,并在同一条命令中叠加用户指定的 `--folder-tokens`、`--doc-types` 等过滤条件。不要把这种联合搜索拆成“标题搜索 + 正文搜索”后自行拼交集;也不要把 `--only-title` 或 `intitle:` 用作主候选路径。只有用户明确只查标题时,才使用 `--only-title` 或 `intitle:`。
|
||||
>
|
||||
> 用户要求最终返回 N 条时,N 是输出上限,不等于 `--page-size N`。逐页根据 `title` 和 `summary_highlighted` 保留同时满足两项条件的候选;有效候选不足 N 且 `has_more=true` 时,保持同一 query 和过滤条件,使用 `--page-token` 继续,最多检查 3 页。摘要不足以判断正文条件时,只对标题已匹配的候选串行读取正文,确认一个再处理下一个,找到 N 条后停止;不要并发拉取正文。检查 3 页后仍不足时,返回已确认结果并建议用户调整标题词、正文词或搜索范围,不要无界扫描。
|
||||
|
||||
### 自然语言 → 命令映射速查
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|---|---|
|
||||
| 标题含某词且正文含某词,限定文件夹内最多 N 个结果(N 为最终输出上限;按上文规则分页筛选,勿作为 `--page-size`) | `lark-cli drive +search --query "标题词 正文词" --folder-tokens <FOLDER_TOKEN>` |
|
||||
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
|
||||
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
|
||||
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
|
||||
@@ -217,7 +222,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
|
||||
- **日历表达**("上个月"、"上周"、"本月"、"前年"、"今年 3 月"等明确日历单位)→ **必须算出绝对 `YYYY-MM-DD` 边界**(如"上个月" = 上一个日历月的 1 号 → 当月 1 号),**不要近似成 `1m`/`2m`**:CLI 里 `m` 是固定 30 天、`y` 固定 365 天,跟日历差 0-3 天,月末月初尤其容易偏出去
|
||||
- 文档中的 `"<YYYY-MM-DD>"` 是运行时占位符:执行命令前按当前日期计算并替换。例如"本月"应替换为本月第一天和下月第一天,不要把示例生成时的月份硬编码进答案
|
||||
- 绝对日期 → 直接 `YYYY-MM-DD` 或 RFC3339
|
||||
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"才继续。单轮翻页上限 5 页。
|
||||
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。用户明确要"全部 / 全量 / 继续翻"时继续;标题词 + 正文词联合搜索尚未找到足够的有效 Top N 候选时,按上文规则最多检查 3 页。其他场景单轮翻页上限 5 页。
|
||||
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。
|
||||
|
||||
## 权限
|
||||
|
||||
@@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxx" \
|
||||
--label-id "7217780879644737539"
|
||||
--label-id '<label-id>' # replace $LABEL_ID before running
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
```
|
||||
|
||||
`drive +inspect` 支持 Drive folder,并且是受支持 Drive URL 的统一解析入口。对文件夹自身权限设置,先通过 `+inspect` 解析 URL,或直接使用 `drive +permission-get-setting --token '<folder_url>'`;传 bare folder token 时必须显式传 `--type folder`。
|
||||
|
||||
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。
|
||||
|
||||
## 目标发现
|
||||
@@ -25,16 +27,16 @@ lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
```
|
||||
|
||||
解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
|
||||
@@ -61,14 +63,42 @@ lark-cli drive metas batch_query \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
读取 public permission:
|
||||
读取权限设置:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url-or-token>' --type '<type>' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
裸 folder token 必须显式传 `--type folder`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' --type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
通过 URL 读取权限设置时可以省略 `--type`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url>' \
|
||||
--as user --format json # replace $LARK_DRIVE_URL before running
|
||||
```
|
||||
|
||||
按需读取直接协作者/授权成员列表:
|
||||
|
||||
```bash
|
||||
lark-cli drive +member-list \
|
||||
--token '<token_or_url>' \
|
||||
--type '<type>' \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。它只声明期望返回的字段,不授予字段级权限:请求用户的 `name` / `avatar` 时还需 `contact:user.base:readonly`(“获取用户基本信息”)。字段权限或数据可见性不足时,接口仍可能成功但省略相应字段;缺字段不能解释为空值。
|
||||
|
||||
按需读取访问统计:
|
||||
|
||||
```bash
|
||||
@@ -160,9 +190,9 @@ lark-cli drive +secure-label-list \
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<url>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
```
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
|
||||
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
|
||||
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“目标公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。优先按实际返回字段解释公开访问、分享、协作者管理、安全与评论设置;复制内容、创建副本、打印、下载等字段只有在当前 CLI schema 和实际响应返回时才可判断。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
|
||||
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
|
||||
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
|
||||
@@ -36,7 +36,7 @@
|
||||
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
|
||||
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。
|
||||
- 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。
|
||||
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 写入前必须使用确认模板;权限申请、目标公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
|
||||
|
||||
## Semantic Rendering
|
||||
@@ -75,7 +75,7 @@
|
||||
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
|
||||
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
|
||||
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前目标公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers the target's current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
|
||||
|
||||
## 定位与治理动作
|
||||
@@ -165,7 +165,7 @@ Evidence fields:
|
||||
|
||||
覆盖情况:
|
||||
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
|
||||
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
- 成功读取目标公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
|
||||
逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
|
||||
|
||||
@@ -233,7 +233,7 @@ URL:<url-or-token-if-url-unavailable>
|
||||
|
||||
覆盖情况:
|
||||
- 当前身份可见目标:<visible_count>
|
||||
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
|
||||
- 已成功检查目标公共访问和协作权限设置:<permission_checked_count>
|
||||
- 读取失败 / 已删除 / 无权限:<failed_count>
|
||||
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
|
||||
|
||||
@@ -355,8 +355,8 @@ Agent 必须回复:
|
||||
- 字段变更:
|
||||
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
|
||||
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚范围:<目标公共访问和协作权限设置快照字段 / 不适用>
|
||||
|
||||
请确认是否进入写入确认。
|
||||
```
|
||||
@@ -407,8 +407,8 @@ Agent 必须回复:
|
||||
- 风险:<risk_level>
|
||||
- 字段变更:
|
||||
- <field>: <old> -> <new>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
|
||||
请确认是否执行。
|
||||
```
|
||||
@@ -419,6 +419,6 @@ Agent 必须回复:
|
||||
已完成:<read checks / writes>
|
||||
验证:<fresh read result or async permission-request approval note>
|
||||
清单状态:<risk_id status updates / not applicable>
|
||||
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
剩余限制:<unsupported_checks / partial facts / approvals>
|
||||
```
|
||||
|
||||
@@ -38,11 +38,11 @@ Risk / Structure: `R2` / `S2`
|
||||
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
|
||||
- 内容审查、过期内容判断或知识质量评分。
|
||||
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner,不建模 backup owner 或负责人绑定关系。
|
||||
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
|
||||
- 文件夹自身公开权限审计或修复。文件夹自身权限设置可以用 `drive +permission-get-setting` 读取;写入是否支持必须以运行时 schema 和明确需求为准,不能猜测执行 `patch type=folder`。
|
||||
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
|
||||
- 未按范围确认的批量写入。
|
||||
|
||||
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
|
||||
协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list` 。
|
||||
|
||||
## Progressive Load Map
|
||||
|
||||
@@ -53,7 +53,7 @@ Risk / Structure: `R2` / `S2`
|
||||
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
|
||||
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
|
||||
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及权限设置读取时使用 `drive +permission-get-setting`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
|
||||
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
|
||||
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
|
||||
@@ -76,9 +76,9 @@ Risk / Structure: `R2` / `S2`
|
||||
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|---------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy,以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check`、`target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action,或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;Drive folder 直接从 URL 路径或显式 `type=folder` 解析,不调用 `drive +inspect`;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token,除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的文件、文件夹或云文档目标执行 `drive +permission-get-setting` 读取自身权限设置;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读目标公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy,则对照 policy;`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`;owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`;治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA;单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
|
||||
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
|
||||
@@ -91,21 +91,23 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
| State | Allowed Command Families | Purpose |
|
||||
|-------|--------------------------|---------|
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析非 folder URL、type、canonical token、title 和 wiki unwrap data;Drive folder 不支持 `+inspect`,必须从 URL 路径或显式 `type=folder` 直接解析 |
|
||||
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
|
||||
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
|
||||
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
|
||||
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
|
||||
| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 |
|
||||
| `FACT_READ` | `drive +permission-get-setting` | 读取支持类型的文件、文件夹或云文档自身权限设置,包括公开访问、分享、协作者管理、安全与评论 |
|
||||
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
|
||||
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
|
||||
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 目标公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
|
||||
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request;只允许单目标、小列表或已明确确认的候选列表逐个执行 |
|
||||
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata,包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive +permission-get-setting` | 验证支持的 metadata,包括 owner、secure-label 和目标公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
|
||||
## Command Patterns
|
||||
|
||||
@@ -119,9 +121,9 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers` 或 `unsupported_checks`。
|
||||
2. 发现阶段必须生成稳定 `path`。不要只保存 title;同名文档必须能通过 path 或 token 区分。
|
||||
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。
|
||||
3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`、`folder`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。
|
||||
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`,URL、owner、密级等 metadata 可能进入 `unsupported_checks`。
|
||||
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
5. `folder` 作为递归容器时先枚举子资源;如用户明确要查询文件夹自身权限设置,可对该文件夹单独执行 `drive +permission-get-setting --token <folder_token> --type folder`。不要执行 raw `permission.public patch type=folder`,除非 schema 和需求都明确支持。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。
|
||||
|
||||
Wiki space / node 发现:
|
||||
@@ -133,7 +135,7 @@ Wiki space / node 发现:
|
||||
|
||||
Drive folder 发现:
|
||||
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +permission-get-setting --token <folder_token> --type folder` 读取该文件夹自身设置。
|
||||
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files`、`has_more` 和 `next_page_token`。不要把第一页数量当作完整范围。
|
||||
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`。
|
||||
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
|
||||
@@ -141,11 +143,11 @@ Drive folder 发现:
|
||||
## Fact Read Rules
|
||||
|
||||
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets` 或 `discovered_targets` 超过 200 个时,必须分批读取并合并结果。
|
||||
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
2. `drive +permission-get-setting` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`;metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`。
|
||||
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
|
||||
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑;差异只体现在目标来源、coverage summary 和输出聚合。
|
||||
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
6. `permission_public` 用户可见含义是“目标公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
7. `drive file.statistics get` 和 `drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
|
||||
8. 访问统计 / 访问记录当前只对 `doc`、`docx`、`sheet`、`bitable`、`mindnote`、`wiki`、`file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
|
||||
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
|
||||
@@ -162,17 +164,17 @@ Drive folder 发现:
|
||||
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
|
||||
- `Unknown`:读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
|
||||
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前目标公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
|
||||
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
|
||||
|
||||
## 写入规则
|
||||
|
||||
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 目标公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
|
||||
- `drive permission.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
|
||||
- 权限设置读取使用 `drive +permission-get-setting`;裸 token 必须传 `--type`,URL 可以自动推断。写入仍使用 `drive permission.public patch`,只 patch 已解析且 schema 明确支持的类型和字段,不要把读取支持的 `folder` 自动外推为可写入。
|
||||
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
|
||||
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
|
||||
- 不要在同一个写入确认中合并密级标签更新和目标公共访问与协作权限设置修改;必须分别确认。
|
||||
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
|
||||
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由,就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
|
||||
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`。
|
||||
@@ -182,8 +184,8 @@ Drive folder 发现:
|
||||
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true` 或 `old_owner_perm` 降权必须单独在确认中高亮。
|
||||
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`,不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
|
||||
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive +permission-get-setting`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于目标公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
|
||||
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
|
||||
|
||||
@@ -194,7 +196,7 @@ Drive folder 发现:
|
||||
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
|
||||
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。
|
||||
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
|
||||
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
|
||||
## 输出策略
|
||||
|
||||
|
||||
@@ -75,15 +75,17 @@ metadata:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**本表只定位「场景 → 用哪条命令、读哪份文档」。参数以「执行前必做」里对应的文档和 `lark-cli slides +<verb> --help` 为准,不要凭记忆或按别的命令类比补参数。**
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 用户要求使用模板 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`lark-slides-create.md`、`slides +create` |
|
||||
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` |
|
||||
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符 |
|
||||
| 绘制图表 | 原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟 | `xml-schema-quick-ref.md`、`slides_chart_demo.xml` |
|
||||
| 绘制表格 | 优先用 `rect` 和 `text` 模拟,其他用 `<table>` | `xml-schema-quick-ref.md` |
|
||||
| 使用图标 | 禁止盲猜 iconType,必须先检索 IconPark,再写 `<icon iconType="...">`,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
@@ -141,9 +143,9 @@ 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-create.md`](references/lark-slides-create.md)、[`lark-slides-xml-presentation-slide-create.md`](references/lark-slides-xml-presentation-slide-create.md)(逐页添加)
|
||||
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.md)
|
||||
@@ -189,20 +191,6 @@ lark-cli auth login --domain slides
|
||||
- 不要在任何位置使用 emoji 图标。
|
||||
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 生成流程
|
||||
|
||||
```text
|
||||
@@ -220,17 +208,18 @@ Step 2: 生成大纲 → 写入 slide_plan.json
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成替代图片,不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
- 读 lark-slides-create.md 定一步创建还是两步创建,并据此构造 `slides +create`;两步创建再读 lark-slides-xml-presentation-slide-create.md 逐页添加
|
||||
- 图片按 lark-slides-media-upload.md 处理;复杂 XML、转义和 3350001 排查按 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 链接
|
||||
```
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
@@ -313,8 +302,9 @@ 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 并保存到本地文件,避免终端输出被截断 |
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT,可选一步添加页面 |
|
||||
| [`+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 的多页大改,不新建链接 |
|
||||
@@ -331,12 +321,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
2. **创建流程**:新建演示文稿用 `slides +create`,一步创建还是两步创建按 [`lark-slides-create.md`](references/lark-slides-create.md) 判断
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -3,13 +3,22 @@
|
||||
|
||||
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
|
||||
|
||||
- 禁止:从完整 <presentation> XML 解析/拆分/重序列化生成提交 payload。
|
||||
- 推荐:提交源直接就是单页 <slide> XML;+create --slides 只接受已经人工/程序直接生成的 slide 数组,不接受由
|
||||
presentation 动态拆出来的数组。
|
||||
提交源必须是直接生成的单页 `<slide>` XML。禁止从完整 `<presentation>` XML 解析、拆分、重序列化出 slide 数组再提交。
|
||||
|
||||
- 最稳:复杂 deck 默认空 deck + 单页 slide create,每次只提交一个 <slide>。
|
||||
本命令只从零创建演示文稿,没有导入本地 PPT 文件的参数。要把已有 PPTX 变成 Slides,用 `drive +import --file <x.pptx> --type slides`,再在导入结果上编辑,流程见 [lark-slides-pptx-template-workflows.md](lark-slides-pptx-template-workflows.md)。
|
||||
|
||||
- 注意:复杂 XML 不适合直接塞命令行,中文、引号、特殊字符较多时,直接拼接 --slides 容易发生 shell 转义或截断。建议将每页 XML 保存为独立文件,使用 `jq --rawfile` 组装 JSON 数组,避免手动处理 XML 引号和换行。
|
||||
## 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md) 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md),必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -49,6 +49,24 @@ ROUNDTRIP_SXSD_ATTRS = {
|
||||
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
|
||||
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
||||
DEFAULT_TABLE_ROW_HEIGHT = 37
|
||||
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
|
||||
TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
|
||||
TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
|
||||
SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
|
||||
CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
|
||||
HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
|
||||
DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
|
||||
GHOST_TEXT_MIN_FONT_SIZE = 96
|
||||
GHOST_TEXT_MAX_ALPHA = 0.5
|
||||
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
|
||||
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
|
||||
# A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
|
||||
# glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
|
||||
# padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
|
||||
LINE_TEXT_GRAZE_MIN_PX = 2.0
|
||||
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
|
||||
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
|
||||
LINE_MIN_VISIBLE_ALPHA = 0.08
|
||||
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
|
||||
# visible defect; keep this well under 1px so real overflow is still always caught.
|
||||
CANVAS_OVERFLOW_TOLERANCE = 0.5
|
||||
@@ -106,6 +124,52 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
||||
return int(value) if value.is_integer() else value
|
||||
|
||||
|
||||
def extract_bool_attribute(tag_source: str, name: str) -> bool:
|
||||
value = extract_attribute(tag_source, name)
|
||||
return value in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def extract_color_alpha(color: str | None) -> int | float | None:
|
||||
if color is None:
|
||||
return None
|
||||
normalized = re.sub(r"\s+", "", color).lower()
|
||||
if normalized == "transparent":
|
||||
return 0
|
||||
rgba_match = re.fullmatch(
|
||||
r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
|
||||
normalized,
|
||||
)
|
||||
if rgba_match is None:
|
||||
return None
|
||||
try:
|
||||
alpha = float(rgba_match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return int(alpha) if alpha.is_integer() else alpha
|
||||
|
||||
|
||||
def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
|
||||
base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
|
||||
color_alpha = extract_color_alpha(text_color)
|
||||
if not isinstance(color_alpha, (int, float)):
|
||||
return base_alpha
|
||||
return base_alpha * color_alpha
|
||||
|
||||
|
||||
def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
|
||||
for tag_name in style_tags:
|
||||
if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
|
||||
for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
|
||||
if extract_bool_attribute(attrs, attr_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sum_sizes(sizes: list[int | float]) -> int | float:
|
||||
return sum(sizes)
|
||||
|
||||
@@ -218,6 +282,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
|
||||
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
|
||||
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
|
||||
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
|
||||
"letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
|
||||
}
|
||||
)
|
||||
return paragraphs
|
||||
@@ -694,6 +759,20 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
font_size = extract_numeric_attribute(content_attrs, "fontSize")
|
||||
if font_size is None:
|
||||
font_size = extract_numeric_attribute(attrs, "fontSize")
|
||||
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
|
||||
text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
|
||||
bold = (
|
||||
extract_bool_attribute(content_attrs, "bold")
|
||||
or extract_bool_attribute(attrs, "bold")
|
||||
or detect_inline_style_presence(content, {"strong", "b"})
|
||||
or detect_any_span_bool_attribute(content, "bold")
|
||||
)
|
||||
italic = (
|
||||
extract_bool_attribute(content_attrs, "italic")
|
||||
or extract_bool_attribute(attrs, "italic")
|
||||
or detect_inline_style_presence(content, {"i", "em"})
|
||||
or detect_any_span_bool_attribute(content, "italic")
|
||||
)
|
||||
element.update(
|
||||
{
|
||||
"textType": extract_attribute(content_attrs, "textType"),
|
||||
@@ -705,11 +784,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
|
||||
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
|
||||
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
|
||||
"letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
|
||||
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
|
||||
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
|
||||
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
|
||||
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
|
||||
"fontSize": font_size if font_size is not None else 16,
|
||||
"fontFamily": font_family or "",
|
||||
"color": text_color,
|
||||
"textAlpha": effective_text_alpha(alpha, text_color),
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"text": strip_xml_paragraphs(content),
|
||||
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
|
||||
}
|
||||
@@ -745,7 +830,11 @@ def is_vertical_text(element: dict[str, Any]) -> bool:
|
||||
|
||||
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
|
||||
text_elements = [
|
||||
element
|
||||
for element in elements
|
||||
if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
|
||||
]
|
||||
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
|
||||
for text_element in text_elements:
|
||||
for image_element in image_elements:
|
||||
@@ -782,22 +871,135 @@ def normalize_text_for_overlap(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def estimate_character_width(character: str, font_size: int | float) -> int | float:
|
||||
SERIF_FONT_PATTERNS = {
|
||||
"song", "songti", "simsun", "ming", "mincho",
|
||||
"georgia", "times", "caslon", "garamond", "sourcehan-serif",
|
||||
"source han serif", "思源宋体", "宋体", "明体",
|
||||
}
|
||||
|
||||
SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
|
||||
"helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
|
||||
|
||||
|
||||
def classify_font_family(font_family: str | None) -> str:
|
||||
if not font_family:
|
||||
return "sans"
|
||||
family_lower = font_family.lower()
|
||||
for marker in SANS_EXPLICIT_MARKERS:
|
||||
if marker in family_lower:
|
||||
return "sans"
|
||||
serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
|
||||
for pattern in serif_keywords:
|
||||
if pattern in family_lower:
|
||||
return "serif"
|
||||
return "sans"
|
||||
|
||||
|
||||
_FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
|
||||
"sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
|
||||
"serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
|
||||
}
|
||||
|
||||
|
||||
def estimate_character_width(
|
||||
character: str,
|
||||
font_size: int | float,
|
||||
bold: bool = False,
|
||||
font_family: str | None = None,
|
||||
) -> int | float:
|
||||
bold_multiplier = 1.05 if bold else 1.0
|
||||
if character.isspace():
|
||||
return font_size * 0.33
|
||||
if unicodedata.east_asian_width(character) in {"F", "W"}:
|
||||
return font_size
|
||||
return font_size * 0.55
|
||||
return font_size * 0.33 * bold_multiplier
|
||||
ea_width = unicodedata.east_asian_width(character)
|
||||
if ea_width in {"F", "W"}:
|
||||
return font_size * bold_multiplier
|
||||
category = classify_font_family(font_family)
|
||||
coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
|
||||
if character.isupper():
|
||||
return font_size * coeffs["upper"] * bold_multiplier
|
||||
if character.islower():
|
||||
return font_size * coeffs["lower"] * bold_multiplier
|
||||
if character.isdigit():
|
||||
return font_size * coeffs["digit"] * bold_multiplier
|
||||
return font_size * coeffs["punct"] * bold_multiplier
|
||||
|
||||
|
||||
def estimate_text_width(text: str, font_size: int | float) -> int | float:
|
||||
return sum(estimate_character_width(character, font_size) for character in text)
|
||||
def estimate_text_width(
|
||||
text: str,
|
||||
font_size: int | float,
|
||||
letter_spacing: int | float = 0,
|
||||
bold: bool = False,
|
||||
font_family: str | None = None,
|
||||
) -> int | float:
|
||||
base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
|
||||
return base + max(len(text) - 1, 0) * letter_spacing
|
||||
|
||||
|
||||
def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
|
||||
if paragraph is not None:
|
||||
value = paragraph.get("letterSpacing")
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
value = element.get("letterSpacing")
|
||||
return value if isinstance(value, (int, float)) else 0
|
||||
|
||||
|
||||
def text_wrap_width_tolerance() -> int | float:
|
||||
return TEXT_WRAP_WIDTH_TOLERANCE_PX
|
||||
|
||||
|
||||
def text_height_overflow_tolerance() -> int | float:
|
||||
return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
|
||||
|
||||
|
||||
def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
|
||||
return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
|
||||
|
||||
|
||||
def is_short_metric_text(text: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
|
||||
return False
|
||||
if re.fullmatch(r"[+\-–—]?[0-9,.,]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
|
||||
return True
|
||||
if re.search(r"[,.,+\-–—/%%]", compact) is None:
|
||||
return False
|
||||
return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,.,/%%\-–—\u4e00-\u9fff]+", compact) is not None
|
||||
|
||||
|
||||
def is_single_line_visual_candidate(
|
||||
element: dict[str, Any],
|
||||
paragraph: dict[str, Any] | None,
|
||||
text: str,
|
||||
logical_width: int | float,
|
||||
effective_width: int | float,
|
||||
) -> bool:
|
||||
if "\n" in text or logical_width <= effective_width:
|
||||
return False
|
||||
if is_short_metric_text(text):
|
||||
return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
|
||||
|
||||
text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
|
||||
compact_len = len(re.sub(r"\s+", "", text))
|
||||
if text_align == "center" and compact_len <= 32:
|
||||
return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
|
||||
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
|
||||
return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
|
||||
return False
|
||||
|
||||
|
||||
def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
bold = element.get("bold", False)
|
||||
font_family = element.get("fontFamily", "")
|
||||
letter_spacing = resolve_letter_spacing(element)
|
||||
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
|
||||
return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
|
||||
return max(
|
||||
[estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
|
||||
or [1]
|
||||
)
|
||||
|
||||
|
||||
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
@@ -810,8 +1012,14 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
|
||||
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
|
||||
|
||||
|
||||
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
|
||||
def estimate_text_line_count_for_text(
|
||||
element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
|
||||
) -> int:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
bold = element.get("bold", False)
|
||||
font_family = element.get("fontFamily", "")
|
||||
letter_spacing = resolve_letter_spacing(element, paragraph)
|
||||
available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
|
||||
hard_lines = text.split("\n")
|
||||
if not text:
|
||||
return 0
|
||||
@@ -820,8 +1028,12 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
|
||||
if element.get("wrap") in {"false", "0"}:
|
||||
line_count += 1
|
||||
continue
|
||||
logical_width = max(estimate_text_width(hard_line, font_size), 1)
|
||||
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
|
||||
logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
|
||||
effective_width = available_width + text_wrap_width_tolerance()
|
||||
if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
|
||||
line_count += 1
|
||||
continue
|
||||
line_count += max(1, math.ceil(logical_width / effective_width))
|
||||
return line_count
|
||||
|
||||
|
||||
@@ -831,7 +1043,8 @@ def estimate_text_line_count(element: dict[str, Any]) -> int:
|
||||
|
||||
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
line_spacing = line_spacing or "multiple:1.5"
|
||||
if line_spacing is None:
|
||||
return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
|
||||
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
||||
if match is None:
|
||||
return None
|
||||
@@ -839,12 +1052,27 @@ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None
|
||||
return font_size * float(value) if spacing_type == "multiple" else float(value)
|
||||
|
||||
|
||||
def adjust_dense_body_line_height(
|
||||
element: dict[str, Any],
|
||||
line_spacing: str | None,
|
||||
line_height: int | float,
|
||||
paragraph_count: int,
|
||||
) -> int | float:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
if paragraph_count < 4 or font_size > 14 or not line_spacing:
|
||||
return line_height
|
||||
match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
||||
if match is None:
|
||||
return line_height
|
||||
return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
|
||||
|
||||
|
||||
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in elements:
|
||||
if not is_text_element(element) or not has_text_content(element):
|
||||
continue
|
||||
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
|
||||
if has_explicit_height_auto_fit(element):
|
||||
continue
|
||||
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
@@ -860,10 +1088,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
estimated_height = 0.0
|
||||
line_heights: list[int | float] = []
|
||||
for paragraph in paragraphs:
|
||||
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
|
||||
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
|
||||
if paragraph_line_count == 0:
|
||||
continue
|
||||
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
|
||||
resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
|
||||
line_height = estimate_text_line_height(element, resolved_line_spacing)
|
||||
before_spacing = estimate_text_line_height(
|
||||
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
|
||||
)
|
||||
@@ -873,6 +1102,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
if line_height is None or before_spacing is None or after_spacing is None:
|
||||
line_count = 0
|
||||
break
|
||||
line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
|
||||
first_line_height = font_size if line_count == 0 else line_height
|
||||
line_count += paragraph_line_count
|
||||
line_heights.append(line_height)
|
||||
@@ -883,12 +1113,24 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
continue
|
||||
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
|
||||
overflow = estimated_height - available_height
|
||||
if overflow <= 0:
|
||||
if overflow <= text_height_overflow_tolerance():
|
||||
continue
|
||||
|
||||
is_background = is_background_decorative_text(element, elements)
|
||||
if is_background:
|
||||
level = "info"
|
||||
else:
|
||||
level = "error" if overflow > 10 else "warning"
|
||||
message = (
|
||||
f'text shape {element["id"]} may overflow its own content box '
|
||||
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
|
||||
'consider setting content wrap="true" autoFit="normal-auto-fit"'
|
||||
)
|
||||
if is_background:
|
||||
message += " (likely background decoration: large font, low alpha, underneath other text)"
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"level": level,
|
||||
"code": "text_may_overflow_shape",
|
||||
"elements": [element["id"]],
|
||||
"line_count": line_count,
|
||||
@@ -896,11 +1138,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
"estimated_height": estimated_height,
|
||||
"available_height": available_height,
|
||||
"overflow": overflow,
|
||||
"message": (
|
||||
f'text shape {element["id"]} may overflow its own content box '
|
||||
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
|
||||
'consider setting content wrap="true" autoFit="normal-auto-fit"'
|
||||
),
|
||||
"message": message,
|
||||
"hint": (
|
||||
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
|
||||
"autoFit=\"normal-auto-fit\". "
|
||||
@@ -911,6 +1149,38 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
return issues
|
||||
|
||||
|
||||
def is_background_decorative_text(
|
||||
element: dict[str, Any], elements: list[dict[str, Any]]
|
||||
) -> bool:
|
||||
if not is_ghost_text(element):
|
||||
return False
|
||||
for other in elements:
|
||||
if other is element:
|
||||
continue
|
||||
if not is_text_element(other) or not has_text_content(other):
|
||||
continue
|
||||
foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
|
||||
if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
|
||||
continue
|
||||
if other["order"] <= element["order"]:
|
||||
continue
|
||||
if intersects(element, other):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_ghost_text(element: dict[str, Any]) -> bool:
|
||||
if not is_text_element(element) or not has_text_content(element):
|
||||
return False
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
text_alpha = element.get("textAlpha", element.get("alpha", 1))
|
||||
if not isinstance(text_alpha, (int, float)):
|
||||
return False
|
||||
if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
|
||||
return True
|
||||
return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
|
||||
|
||||
|
||||
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
|
||||
return None
|
||||
@@ -1022,6 +1292,8 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
||||
return False
|
||||
if not (has_text_content(left) and has_text_content(right)):
|
||||
return False
|
||||
if is_ghost_text(left) or is_ghost_text(right):
|
||||
return False
|
||||
if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
|
||||
return False
|
||||
|
||||
@@ -1038,13 +1310,16 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
||||
return False
|
||||
|
||||
font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
|
||||
padding_left = source.get("paddingLeft", 0)
|
||||
padding_right = source.get("paddingRight", 0)
|
||||
available_width = max(source["width"] - padding_left - padding_right, 1)
|
||||
visual_width = estimate_text_max_line_width(source)
|
||||
overflow_width = visual_width - source["width"]
|
||||
min_overflow = max(font_size * 1.5, source["width"] * 0.08)
|
||||
overflow_width = visual_width - available_width
|
||||
min_overflow = max(font_size * 1.5, available_width * 0.08)
|
||||
if overflow_width < min_overflow:
|
||||
return False
|
||||
|
||||
intrusion_width = source["x"] + visual_width - target["x"]
|
||||
intrusion_width = source["x"] + padding_left + visual_width - target["x"]
|
||||
min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
|
||||
if intrusion_width < min_intrusion:
|
||||
return False
|
||||
@@ -1056,8 +1331,9 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
||||
|
||||
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
|
||||
source, target = sorted([left, right], key=lambda element: element["x"])
|
||||
padding_left = source.get("paddingLeft", 0)
|
||||
visual_width = estimate_text_max_line_width(source)
|
||||
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
|
||||
source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
|
||||
width = intersection_width(source_visual_bbox, target)
|
||||
height = intersection_height(source_visual_bbox, target)
|
||||
return {
|
||||
@@ -1072,6 +1348,8 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
return False
|
||||
if is_text_element(right) and not has_text_content(right):
|
||||
return False
|
||||
if is_ghost_text(left) or is_ghost_text(right):
|
||||
return False
|
||||
if is_template_text_stack(left, right):
|
||||
return False
|
||||
if is_text_element(left) and is_text_element(right):
|
||||
@@ -1118,6 +1396,8 @@ def should_report_whiteboard_overlap(
|
||||
) -> dict[str, Any] | None:
|
||||
if other is whiteboard or not intersects(whiteboard, other):
|
||||
return None
|
||||
if is_ghost_text(other):
|
||||
return None
|
||||
if contains(whiteboard, other):
|
||||
return None
|
||||
if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
|
||||
@@ -1194,6 +1474,8 @@ def detect_whiteboard_external_overlaps(
|
||||
|
||||
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
||||
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
||||
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
||||
return bbox
|
||||
rotation = element["rotation"]
|
||||
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
||||
rotation = 0
|
||||
@@ -1219,7 +1501,12 @@ def detect_elements_out_of_canvas(
|
||||
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in elements:
|
||||
for element in (
|
||||
element
|
||||
for element in elements
|
||||
if element["kind"] in {"table", "chart"}
|
||||
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
|
||||
):
|
||||
bbox = element_canvas_bbox(element)
|
||||
overflow = {
|
||||
"left": max(-bbox["x"], 0),
|
||||
@@ -1329,6 +1616,93 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
|
||||
return issues
|
||||
|
||||
|
||||
def segment_intersects_rect(
|
||||
x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
|
||||
) -> bool:
|
||||
"""True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
|
||||
left = rect["x"]
|
||||
top = rect["y"]
|
||||
right = rect["x"] + rect["width"]
|
||||
bottom = rect["y"] + rect["height"]
|
||||
if right <= left or bottom <= top:
|
||||
return False
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
if dx == 0 and dy == 0:
|
||||
return left <= x1 <= right and top <= y1 <= bottom
|
||||
t_enter, t_exit = 0.0, 1.0
|
||||
for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
|
||||
if delta == 0:
|
||||
if distance < 0:
|
||||
return False
|
||||
continue
|
||||
t = distance / delta
|
||||
if delta < 0:
|
||||
t_enter = max(t_enter, t)
|
||||
else:
|
||||
t_exit = min(t_exit, t)
|
||||
if t_enter > t_exit:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def line_text_graze_margin(text_element: dict[str, Any]) -> float:
|
||||
font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
|
||||
return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
|
||||
|
||||
|
||||
def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
|
||||
width = rect["width"] - 2 * margin
|
||||
height = rect["height"] - 2 * margin
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
|
||||
|
||||
|
||||
def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
|
||||
if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
|
||||
return False
|
||||
if not is_text_element(text_element) or not has_text_content(text_element):
|
||||
return False
|
||||
if is_ghost_text(text_element) or is_decorative_text(text_element):
|
||||
return False
|
||||
glyph_bbox = estimate_text_visual_bbox(text_element)
|
||||
if glyph_bbox is None:
|
||||
return False
|
||||
# Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
|
||||
# frame is exempt; only a line that actually cuts through the letterforms is a crossing.
|
||||
target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
|
||||
if target is None:
|
||||
return False
|
||||
return segment_intersects_rect(
|
||||
line["startX"], line["startY"], line["endX"], line["endY"], target
|
||||
)
|
||||
|
||||
|
||||
def detect_line_text_crossings(
|
||||
slide_xml: str, elements: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
lines = extract_line_elements(slide_xml)
|
||||
if not lines:
|
||||
return []
|
||||
text_elements = [element for element in elements if is_text_element(element)]
|
||||
issues: list[dict[str, Any]] = []
|
||||
for line in lines:
|
||||
for text_element in text_elements:
|
||||
if not line_crosses_text(line, text_element):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "bbox_overlap",
|
||||
"elements": [line["id"], text_element["id"]],
|
||||
"message": f'line {line["id"]} crosses text {text_element["id"]}',
|
||||
"hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(
|
||||
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
||||
) -> dict[str, Any]:
|
||||
@@ -1339,6 +1713,7 @@ def lint_slide(
|
||||
*detect_table_layout_size_mismatches(elements),
|
||||
*detect_text_may_overflow_shapes(elements),
|
||||
*detect_image_text_occlusions(elements),
|
||||
*detect_line_text_crossings(slide_xml, elements),
|
||||
]
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
@@ -1944,7 +2319,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements: list[dict[str, Any]] = []
|
||||
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
|
||||
for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
start_x = extract_numeric_attribute(attrs, "startX")
|
||||
start_y = extract_numeric_attribute(attrs, "startY")
|
||||
@@ -1953,6 +2328,15 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
if any(value is None for value in (start_x, start_y, end_x, end_y)):
|
||||
continue
|
||||
line_alpha = extract_numeric_attribute(attrs, "alpha")
|
||||
base_alpha = line_alpha if line_alpha is not None else 1
|
||||
border_alpha = 1
|
||||
if match.group(2) != "/":
|
||||
close_index = slide_xml.find("</line>", match.end())
|
||||
body = slide_xml[match.end() : close_index] if close_index != -1 else ""
|
||||
border_attrs = extract_tag_attributes(body, "border")
|
||||
color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
|
||||
if isinstance(color_alpha, (int, float)):
|
||||
border_alpha = color_alpha
|
||||
elements.append(
|
||||
{
|
||||
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
|
||||
@@ -1962,8 +2346,12 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"y": min(start_y, end_y),
|
||||
"width": abs(end_x - start_x),
|
||||
"height": abs(end_y - start_y),
|
||||
"startX": start_x,
|
||||
"startY": start_y,
|
||||
"endX": end_x,
|
||||
"endY": end_y,
|
||||
"rotation": 0,
|
||||
"alpha": line_alpha if line_alpha is not None else 1,
|
||||
"alpha": base_alpha * border_alpha,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
@@ -1976,8 +2364,6 @@ def normalize_issue(
|
||||
elements_by_id: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
normalized = dict(issue)
|
||||
if normalized.get("level") == "info":
|
||||
normalized["level"] = "warning"
|
||||
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
|
||||
normalized["schema_version"] = "2.0"
|
||||
normalized["element_ids"] = element_ids
|
||||
@@ -2039,8 +2425,10 @@ def build_result(
|
||||
) -> dict[str, Any]:
|
||||
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
|
||||
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
|
||||
document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
|
||||
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
|
||||
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
|
||||
info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
|
||||
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
|
||||
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
|
||||
status = slide_status(all_errors, all_warnings)
|
||||
@@ -2053,6 +2441,7 @@ def build_result(
|
||||
"slide_count": len(slides),
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
"status": status,
|
||||
"release_ready": error_count == 0,
|
||||
"screenshot_review_required": warning_count > 0,
|
||||
@@ -2060,6 +2449,7 @@ def build_result(
|
||||
"document": {
|
||||
"errors": document_errors,
|
||||
"warnings": document_warnings,
|
||||
"infos": document_infos,
|
||||
},
|
||||
"slides": slides,
|
||||
}
|
||||
@@ -2150,6 +2540,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
]
|
||||
errors = [issue for issue in issues if issue["level"] == "error"]
|
||||
warnings = [issue for issue in issues if issue["level"] == "warning"]
|
||||
infos = [issue for issue in issues if issue["level"] == "info"]
|
||||
slides.append(
|
||||
{
|
||||
"slide_number": slide_number,
|
||||
@@ -2157,6 +2548,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
"element_count": len(elements_by_id),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"infos": infos,
|
||||
"issues": issues,
|
||||
}
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
69
tests/cli_e2e/base/base_form_questions_dryrun_test.go
Normal file
69
tests/cli_e2e/base/base_form_questions_dryrun_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateVisibleRuleDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-create",
|
||||
"--base-token", "bascnXXXX",
|
||||
"--table-id", "tblXXXX",
|
||||
"--form-id", "vewXXXX",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/bascnXXXX/tables/tblXXXX/forms/vewXXXX/questions")
|
||||
assert.Contains(t, output, `"method": "POST"`)
|
||||
// visible_rule must be transcribed verbatim into the request body.
|
||||
assert.Contains(t, output, "visible_rule")
|
||||
assert.Contains(t, output, "是否需要发票")
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateVisibleRuleDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-update",
|
||||
"--base-token", "bascnXXXX",
|
||||
"--table-id", "tblXXXX",
|
||||
"--form-id", "vewXXXX",
|
||||
"--questions", `[{"id":"q_002","visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/bascnXXXX/tables/tblXXXX/forms/vewXXXX/questions")
|
||||
assert.Contains(t, output, `"method": "PATCH"`)
|
||||
assert.Contains(t, output, "visible_rule")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
|
||||
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
|
||||
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
|
||||
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
|
||||
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
|
||||
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
|
||||
@@ -51,10 +52,10 @@
|
||||
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-list | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-create | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
|
||||
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
|
||||
|
||||
117
tests/cli_e2e/calendar/calendar_description_rich_dryrun_test.go
Normal file
117
tests/cli_e2e/calendar/calendar_description_rich_dryrun_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// richTextMarkdown is a Markdown rich-text payload carrying a doc link and
|
||||
// styling. The CLI forwards it verbatim in description_rich; the OpenAPI service
|
||||
// converts Markdown <-> ClientVars.
|
||||
const richTextMarkdown = "见 [设计文档](https://bytedance.feishu.cn/docx/abc) 和 **重点**"
|
||||
|
||||
// TestCalendar_CreateDescriptionRichDryRun verifies that +create treats
|
||||
// --description as Markdown rich text and forwards it as-is under the
|
||||
// description_rich body field, omitting the plain description field — the
|
||||
// service treats the two as mutually exclusive.
|
||||
func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+create",
|
||||
"--calendar-id", "cal_dry",
|
||||
"--summary", "rich dry-run",
|
||||
"--start", "2026-04-25T10:00:00+08:00",
|
||||
"--end", "2026-04-25T11:00:00+08:00",
|
||||
"--description", richTextMarkdown,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, richTextMarkdown, clie2e.DryRunGet(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
|
||||
require.False(t, clie2e.DryRunGet(out, "api.0.body.description").Exists(), "plain description must not be sent; stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestCalendar_CreateDescriptionRichOnlyDryRun verifies that +create forwards
|
||||
// --description under description_rich and omits the plain description body
|
||||
// field entirely. Sending an empty description would suppress the server's
|
||||
// plain-preview backfill and break first-load rendering.
|
||||
func TestCalendar_CreateDescriptionRichOnlyDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+create",
|
||||
"--calendar-id", "cal_dry",
|
||||
"--summary", "rich only dry-run",
|
||||
"--start", "2026-04-25T10:00:00+08:00",
|
||||
"--end", "2026-04-25T11:00:00+08:00",
|
||||
"--description", richTextMarkdown,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, richTextMarkdown, clie2e.DryRunGet(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
|
||||
require.False(t, clie2e.DryRunGet(out, "api.0.body.description").Exists(), "description must be omitted when not set; stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestCalendar_UpdateDescriptionRichDryRun verifies that +update forwards the
|
||||
// rich-text payload as-is under the description_rich body field.
|
||||
func TestCalendar_UpdateDescriptionRichDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+update",
|
||||
"--calendar-id", "cal_dry",
|
||||
"--event-id", "evt_dry",
|
||||
"--description", richTextMarkdown,
|
||||
"--notify=false",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "PATCH", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, richTextMarkdown, clie2e.DryRunGet(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
|
||||
}
|
||||
@@ -9,19 +9,21 @@
|
||||
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
|
||||
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
|
||||
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
|
||||
- TestDocs_LocalResourcesWorkflowAsBot / AsUser: prove the full local image + file lifecycle for `docs +create` and `docs +update --command append`: placeholder correlation, distinct media block IDs, local image intrinsic-dimension detection, model display-size conversion to persisted `scale`, invalid `width`/`height`/`size` normalization, media upload, token binding, response scrubbing, XML/Markdown fetch verification, exported-Markdown replay with image caption restoration, and cleanup.
|
||||
- TestDocs_LocalResourcesDryRun: proves both `docs +create` and `docs +update --command append` expose the complete no-network request plan for local images and files: placeholder content with intrinsic dimensions, media uploads, image binding with intrinsic `width`/`height` plus converted `scale`, file binding, conditional verification, and failure cleanup.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
|
||||
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
|
||||
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
|
||||
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
|
||||
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
|
||||
- Blocked area: standalone media and search shortcuts still need dedicated deterministic workflows; local resource authoring through create/update is covered.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_local_resources_workflow_test.go::TestDocs_LocalResourcesWorkflowAsBot/create image and source; docs_local_resources_workflow_test.go::TestDocs_LocalResourcesWorkflowAsUser/create image and source; docs_local_resources_dryrun_test.go::TestDocs_LocalResourcesDryRun/create; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title`; XML `<img path="@relative" width="display-px">` + `<source path="@relative">` | local-resource workflows assert returned image/file block IDs and bound tokens; image binding preserves intrinsic dimensions and converts display size to `scale` |
|
||||
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_local_resources_workflow_test.go::testDocsLocalResourcesWorkflow/fetch verifies persisted resources; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown|xml`; `--detail full`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | local-resource fetch asserts captions/file names persist and internal markers/paths do not leak |
|
||||
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
|
||||
@@ -29,5 +31,5 @@
|
||||
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
|
||||
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
|
||||
| ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E |
|
||||
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content`; optional `--reference-map` -> body `reference_map` | |
|
||||
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_local_resources_workflow_test.go::testDocsLocalResourcesWorkflow/append image and source; docs_local_resources_dryrun_test.go::TestDocs_LocalResourcesDryRun/update append; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite|append`; `--doc-format markdown|xml`; `--content`; local `<img path>` / `<source path>`; optional `--reference-map` -> body `reference_map` | local resources are covered under both bot and user identities |
|
||||
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |
|
||||
|
||||
100
tests/cli_e2e/docs/docs_local_resources_dryrun_test.go
Normal file
100
tests/cli_e2e/docs/docs_local_resources_dryrun_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDocs_LocalResourcesDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
workDir := t.TempDir()
|
||||
writeLocalResourceFixture(t, workDir, "dry-run.png", hundredByEightyPNG)
|
||||
writeLocalResourceFixture(t, workDir, "dry-run.txt", []byte("dry-run source fixture\n"))
|
||||
content := `<p>dry-run resources</p><img path="@dry-run.png" caption="dry-run image" width="50"/><source path="@dry-run.txt" name="dry-run-report.txt"/>`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDocumentURL string
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
args: []string{
|
||||
"docs", "+create",
|
||||
"--title", "Local resources dry-run",
|
||||
"--content", content,
|
||||
"--dry-run",
|
||||
},
|
||||
wantDocumentURL: "/open-apis/docs_ai/v1/documents",
|
||||
},
|
||||
{
|
||||
name: "update append",
|
||||
args: []string{
|
||||
"docs", "+update",
|
||||
"--doc", "doxcnLocalResourcesDryRun",
|
||||
"--command", "append",
|
||||
"--content", content,
|
||||
"--dry-run",
|
||||
},
|
||||
wantDocumentURL: "/open-apis/docs_ai/v1/documents/doxcnLocalResourcesDryRun",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
apis := clie2e.DryRunGet(result.Stdout, "api").Array()
|
||||
require.Len(t, apis, 6, "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, tt.wantDocumentURL, apis[0].Get("url").String(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
preparedContent := apis[0].Get("body.content").String()
|
||||
require.Contains(t, preparedContent, "dry-run image")
|
||||
require.Contains(t, preparedContent, "dry-run-report.txt")
|
||||
require.NotContains(t, preparedContent, "@dry-run.png")
|
||||
require.NotContains(t, preparedContent, "@dry-run.txt")
|
||||
require.Equal(t, 2, strings.Count(preparedContent, "@lcli_"), "prepared content:\n%s", preparedContent)
|
||||
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", apis[1].Get("url").String())
|
||||
require.Equal(t, "docx_image", apis[1].Get("body.parent_type").String())
|
||||
require.Equal(t, "<local_image_1_block_id>", apis[1].Get("body.parent_node").String())
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", apis[2].Get("url").String())
|
||||
require.Equal(t, "docx_file", apis[2].Get("body.parent_type").String())
|
||||
require.Equal(t, "<local_file_2_block_id>", apis[2].Get("body.parent_node").String())
|
||||
|
||||
require.Contains(t, apis[3].Get("url").String(), "/open-apis/docx/v1/documents/")
|
||||
require.Contains(t, apis[3].Get("url").String(), "/blocks/batch_update")
|
||||
require.NotEmpty(t, apis[3].Get("params.client_token").String())
|
||||
require.Equal(t, "<uploaded_file_token_1>", apis[3].Get("body.requests.0.replace_image.token").String())
|
||||
require.Equal(t, int64(100), apis[3].Get("body.requests.0.replace_image.width").Int())
|
||||
require.Equal(t, int64(80), apis[3].Get("body.requests.0.replace_image.height").Int())
|
||||
require.InDelta(t, 0.5, apis[3].Get("body.requests.0.replace_image.scale").Float(), 0.000001)
|
||||
require.Equal(t, "<uploaded_file_token_2>", apis[3].Get("body.requests.1.replace_file.token").String())
|
||||
|
||||
require.Equal(t, "GET", apis[4].Get("method").String())
|
||||
require.Equal(t, "PUT", apis[5].Get("method").String())
|
||||
require.Contains(t, apis[5].Get("url").String(), "/open-apis/docs_ai/v1/documents/")
|
||||
require.Equal(t, "block_delete", apis[5].Get("body.command").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
309
tests/cli_e2e/docs/docs_local_resources_workflow_test.go
Normal file
309
tests/cli_e2e/docs/docs_local_resources_workflow_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDocs_LocalResourcesWorkflowAsBot(t *testing.T) {
|
||||
testDocsLocalResourcesWorkflow(t, "bot")
|
||||
}
|
||||
|
||||
func TestDocs_LocalResourcesWorkflowAsUser(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
testDocsLocalResourcesWorkflow(t, "user")
|
||||
}
|
||||
|
||||
func testDocsLocalResourcesWorkflow(t *testing.T, defaultAs string) {
|
||||
t.Helper()
|
||||
if os.Getenv("LARK_DOC_LOCAL_RESOURCES_E2E") != "1" {
|
||||
t.Skip("set LARK_DOC_LOCAL_RESOURCES_E2E=1 and use a server lane with local-resource placeholder support")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
workDir := t.TempDir()
|
||||
createdSource := []byte("created source fixture\n")
|
||||
appendedNegativeSource := []byte("appended negative source fixture\n")
|
||||
appendedNonNumericSource := []byte("appended nonnumeric source fixture\n")
|
||||
writeLocalResourceFixture(t, workDir, "created.png", hundredByEightyPNG)
|
||||
writeLocalResourceFixture(t, workDir, "created.txt", createdSource)
|
||||
writeLocalResourceFixture(t, workDir, "appended.png", onePixelPNG)
|
||||
writeLocalResourceFixture(t, workDir, "appended-negative.txt", appendedNegativeSource)
|
||||
writeLocalResourceFixture(t, workDir, "appended-nonnumeric.txt", appendedNonNumericSource)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
parentT := t
|
||||
folderToken := ""
|
||||
cleanupAs := defaultAs
|
||||
if defaultAs == "bot" {
|
||||
// Bot-created documents grant the current CLI user full access, while
|
||||
// the shared PPE bot intentionally lacks Drive delete scopes.
|
||||
cleanupAs = "user"
|
||||
} else {
|
||||
folderToken = drive.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-local-resources-"+suffix, defaultAs, "")
|
||||
}
|
||||
var docToken string
|
||||
var roundTripDocToken string
|
||||
var roundTripContent string
|
||||
|
||||
t.Run("create image and source", func(t *testing.T) {
|
||||
args := []string{
|
||||
"docs", "+create",
|
||||
"--title", "lark-cli local resources " + suffix,
|
||||
"--content", `<p>created resources</p><img path="@created.png" caption="created image" width="50"/><source path="@created.txt" name="created-report.txt" size="0"/>`,
|
||||
}
|
||||
if folderToken != "" {
|
||||
args = append(args, "--parent-token", folderToken)
|
||||
}
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
DefaultAs: defaultAs,
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assertBoundLocalResourceBlocks(t, result.Stdout, 1, 1)
|
||||
|
||||
docToken = gjson.Get(result.Stdout, "data.document.document_id").String()
|
||||
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, docToken, "docx", cleanupAs)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete doc "+docToken, deleteResult, deleteErr)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("append image and source", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before update")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+update",
|
||||
"--doc", docToken,
|
||||
"--command", "append",
|
||||
"--content", `<p>appended resources</p><img path="@appended.png" caption="appended image" width="invalid" height="0"/><source path="@appended-negative.txt" name="appended-negative-report.txt" size="-2"/><source path="@appended-nonnumeric.txt" name="appended-nonnumeric-report.txt" size="invalid"/>`,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assertBoundLocalResourceBlocks(t, result.Stdout, 1, 2)
|
||||
})
|
||||
|
||||
t.Run("fetch verifies persisted resources", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before fetch")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", docToken,
|
||||
"--doc-format", "xml",
|
||||
"--detail", "full",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
content := gjson.Get(result.Stdout, "data.document.content").String()
|
||||
for _, want := range []string{
|
||||
"created image",
|
||||
"appended image",
|
||||
"created-report.txt",
|
||||
"appended-negative-report.txt",
|
||||
"appended-nonnumeric-report.txt",
|
||||
} {
|
||||
require.Contains(t, content, want, "fetched XML:\n%s", content)
|
||||
}
|
||||
require.NotContains(t, content, "@lcli_", "fetched XML leaked internal correlation marker")
|
||||
require.NotContains(t, content, "@created.", "fetched XML leaked create fixture path")
|
||||
require.NotContains(t, content, "@appended.", "fetched XML leaked append fixture path")
|
||||
assertFetchedImagePresentation(t, content, "created image", 100, 80, 0.5)
|
||||
})
|
||||
|
||||
t.Run("fetch markdown preserves resource metadata", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before fetch")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", docToken,
|
||||
"--doc-format", "markdown",
|
||||
"--detail", "full",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
content := gjson.Get(result.Stdout, "data.document.content").String()
|
||||
for _, want := range []string{"
|
||||
}
|
||||
assertMarkdownSourceMetadata(t, content, "created-report.txt", len(createdSource))
|
||||
assertMarkdownSourceMetadata(t, content, "appended-negative-report.txt", len(appendedNegativeSource))
|
||||
assertMarkdownSourceMetadata(t, content, "appended-nonnumeric-report.txt", len(appendedNonNumericSource))
|
||||
require.NotContains(t, content, "@lcli_", "fetched Markdown leaked internal correlation marker")
|
||||
require.NotContains(t, content, "@created.", "fetched Markdown leaked create fixture path")
|
||||
require.NotContains(t, content, "@appended.", "fetched Markdown leaked append fixture path")
|
||||
|
||||
roundTripContent = content
|
||||
})
|
||||
|
||||
t.Run("create from exported markdown restores image captions", func(t *testing.T) {
|
||||
require.NotEmpty(t, roundTripContent, "Markdown content should be fetched before replay")
|
||||
args := []string{
|
||||
"docs", "+create",
|
||||
"--title", "lark-cli markdown replay " + suffix,
|
||||
"--doc-format", "markdown",
|
||||
"--content", "-",
|
||||
}
|
||||
if folderToken != "" {
|
||||
args = append(args, "--parent-token", folderToken)
|
||||
}
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
DefaultAs: defaultAs,
|
||||
Stdin: []byte(roundTripContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
roundTripDocToken = gjson.Get(result.Stdout, "data.document.document_id").String()
|
||||
require.NotEmpty(t, roundTripDocToken, "stdout:\n%s", result.Stdout)
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, roundTripDocToken, "docx", cleanupAs)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete markdown replay doc "+roundTripDocToken, deleteResult, deleteErr)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("fetch markdown replay verifies captions and source metadata", func(t *testing.T) {
|
||||
require.NotEmpty(t, roundTripDocToken, "Markdown replay document should be created before fetch")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", roundTripDocToken,
|
||||
"--doc-format", "xml",
|
||||
"--detail", "full",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
content := gjson.Get(result.Stdout, "data.document.content").String()
|
||||
for _, want := range []string{
|
||||
`caption="created image`,
|
||||
`caption="appended image`,
|
||||
} {
|
||||
require.Contains(t, content, want, "replayed XML:\n%s", content)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"created-report.txt",
|
||||
"appended-negative-report.txt",
|
||||
"appended-nonnumeric-report.txt",
|
||||
} {
|
||||
require.Contains(t, content, want, "replayed XML:\n%s", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var markdownSourceTagPattern = regexp.MustCompile(`(?s)<source\b[^>]*>`)
|
||||
|
||||
func assertMarkdownSourceMetadata(t *testing.T, content, wantName string, wantSize int) {
|
||||
t.Helper()
|
||||
wantNameAttr := fmt.Sprintf(`name="%s"`, wantName)
|
||||
for _, tag := range markdownSourceTagPattern.FindAllString(content, -1) {
|
||||
if !strings.Contains(tag, wantNameAttr) {
|
||||
continue
|
||||
}
|
||||
require.Contains(t, tag, fmt.Sprintf(`size="%d"`, wantSize), "source tag in fetched Markdown:\n%s", tag)
|
||||
return
|
||||
}
|
||||
require.Failf(t, "source metadata not found", "fetched Markdown has no source tag with %s:\n%s", wantNameAttr, content)
|
||||
}
|
||||
|
||||
func assertBoundLocalResourceBlocks(t *testing.T, stdout string, wantImages, wantFiles int) {
|
||||
t.Helper()
|
||||
counts := map[string]int{"image": 0, "file": 0}
|
||||
blockIDs := make(map[string]struct{}, wantImages+wantFiles)
|
||||
for _, block := range gjson.Get(stdout, "data.document.new_blocks").Array() {
|
||||
blockType := block.Get("block_type").String()
|
||||
if _, tracked := counts[blockType]; !tracked {
|
||||
continue
|
||||
}
|
||||
counts[blockType]++
|
||||
blockID := block.Get("block_id").String()
|
||||
require.NotEmpty(t, blockID, "%s block has no block_id: %s", blockType, block.Raw)
|
||||
require.NotContains(t, blockIDs, blockID, "multiple local resources reused block_id %s: %s", blockID, stdout)
|
||||
blockIDs[blockID] = struct{}{}
|
||||
token := block.Get("block_token").String()
|
||||
require.NotEmpty(t, token, "%s block has no bound token: %s", blockType, block.Raw)
|
||||
require.False(t, strings.HasPrefix(token, "@lcli_"), "%s block leaked marker: %s", blockType, block.Raw)
|
||||
}
|
||||
require.Equal(t, wantImages, counts["image"], "image blocks in stdout:\n%s", stdout)
|
||||
require.Equal(t, wantFiles, counts["file"], "file blocks in stdout:\n%s", stdout)
|
||||
}
|
||||
|
||||
func writeLocalResourceFixture(t *testing.T, dir, name string, data []byte) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o600))
|
||||
}
|
||||
|
||||
func assertFetchedImagePresentation(t *testing.T, content, caption string, width, height int, scale float64) {
|
||||
t.Helper()
|
||||
for _, tag := range regexp.MustCompile(`(?s)<img\b[^>]*>`).FindAllString(content, -1) {
|
||||
if !strings.Contains(tag, fmt.Sprintf(`caption="%s"`, caption)) {
|
||||
continue
|
||||
}
|
||||
require.Contains(t, tag, fmt.Sprintf(`width="%d"`, width), "image tag in fetched XML:\n%s", tag)
|
||||
require.Contains(t, tag, fmt.Sprintf(`height="%d"`, height), "image tag in fetched XML:\n%s", tag)
|
||||
scaleMatch := regexp.MustCompile(`\bscale="([^"]+)"`).FindStringSubmatch(tag)
|
||||
require.Len(t, scaleMatch, 2, "image tag has no scale: %s", tag)
|
||||
var gotScale float64
|
||||
_, err := fmt.Sscanf(scaleMatch[1], "%f", &gotScale)
|
||||
require.NoError(t, err, "parse image scale from %s", tag)
|
||||
require.InDelta(t, scale, gotScale, 0.000001, "image tag in fetched XML:\n%s", tag)
|
||||
return
|
||||
}
|
||||
require.Failf(t, "image presentation not found", "fetched XML has no image with caption %q:\n%s", caption, content)
|
||||
}
|
||||
|
||||
func encodePNGFixture(width, height int) []byte {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, width, height))); err != nil {
|
||||
panic(fmt.Sprintf("encode embedded %dx%d PNG fixture: %v", width, height, err))
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
var (
|
||||
onePixelPNG = encodePNGFixture(1, 1)
|
||||
hundredByEightyPNG = encodePNGFixture(100, 80)
|
||||
)
|
||||
165
tests/cli_e2e/drive/drive_member_list_test.go
Normal file
165
tests/cli_e2e/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_MemberListDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantType string
|
||||
wantFields string
|
||||
wantPermType string
|
||||
}{
|
||||
{
|
||||
name: "bare folder token",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "fldE2E001",
|
||||
"--type", "folder",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/fldE2E001/members",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "folder URL infers folder type",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldE2E002?from=share",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/fldE2E002/members",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "fields star is passed only when explicit",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "doxE2E003",
|
||||
"--type", "docx",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxE2E003/members",
|
||||
wantType: "docx",
|
||||
wantFields: "*",
|
||||
},
|
||||
{
|
||||
name: "wiki perm type",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "wikE2E004",
|
||||
"--type", "wiki",
|
||||
"--fields", "name,type",
|
||||
"--perm-type", "single_page",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/wikE2E004/members",
|
||||
wantType: "wiki",
|
||||
wantFields: "name,type",
|
||||
wantPermType: "single_page",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
|
||||
}
|
||||
if tt.wantFields == "" {
|
||||
if gjson.Get(out, "data.api.0.params.fields").Exists() {
|
||||
t.Fatalf("params.fields should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := gjson.Get(out, "data.api.0.params.fields").String(); got != tt.wantFields {
|
||||
t.Fatalf("params.fields = %q, want %q\nstdout:\n%s", got, tt.wantFields, out)
|
||||
}
|
||||
if tt.wantPermType == "" {
|
||||
if gjson.Get(out, "data.api.0.params.perm_type").Exists() {
|
||||
t.Fatalf("params.perm_type should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := gjson.Get(out, "data.api.0.params.perm_type").String(); got != tt.wantPermType {
|
||||
t.Fatalf("params.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_MemberListWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
folderName := "lark-cli-e2e-drive-member-list-" + clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", folderToken,
|
||||
"--type", "folder",
|
||||
"--format", "json",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode != 0 {
|
||||
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combinedOutput, "docs:permission.member:retrieve") ||
|
||||
strings.Contains(combinedOutput, "app scope not enabled") ||
|
||||
strings.Contains(combinedOutput, "missing required scope") ||
|
||||
strings.Contains(combinedOutput, "missing_scope") ||
|
||||
strings.Contains(combinedOutput, "99991672") ||
|
||||
strings.Contains(combinedOutput, "1063002") ||
|
||||
strings.Contains(combinedOutput, "1063004") ||
|
||||
strings.Contains(combinedOutput, "permission denied") ||
|
||||
strings.Contains(combinedOutput, "no share permission") {
|
||||
t.Skipf("skip drive member list workflow due to missing bot scope or folder permission: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
|
||||
}
|
||||
if strings.Contains(combinedOutput, "99992402") &&
|
||||
strings.Contains(combinedOutput, "field validation failed") {
|
||||
t.Skipf("skip drive member list workflow because this environment does not yet accept type=folder on the member list API: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
|
||||
}
|
||||
t.Fatalf("drive member list workflow failed: exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
if items := gjson.Get(result.Stdout, "data.items"); !items.Exists() || !items.IsArray() {
|
||||
t.Fatalf("data.items must be present as an array\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
}
|
||||
130
tests/cli_e2e/drive/drive_permission_get_setting_test.go
Normal file
130
tests/cli_e2e/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_PermissionGetSettingDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "bare folder token",
|
||||
args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", "fldE2E001",
|
||||
"--type", "folder",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "folder URL",
|
||||
args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldE2E001?from=share",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", "https://example.feishu.cn/docx/doxE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxE2E001/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
|
||||
}
|
||||
if gjson.Get(out, "data.folder_token").Exists() {
|
||||
t.Fatalf("folder_token exists in dry-run output, want omitted\nstdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PermissionGetSettingWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
folderToken := CreateDriveFolder(
|
||||
t,
|
||||
t,
|
||||
ctx,
|
||||
"lark-cli-e2e-drive-permission-get-setting-"+clie2e.GenerateSuffix(),
|
||||
"bot",
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", folderToken,
|
||||
"--type", "folder",
|
||||
"--format", "json",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode != 0 {
|
||||
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combinedOutput, "docs:permission.setting:read") ||
|
||||
strings.Contains(combinedOutput, "app scope not enabled") ||
|
||||
strings.Contains(combinedOutput, "missing required scope") ||
|
||||
strings.Contains(combinedOutput, "99991672") {
|
||||
t.Skipf("skip drive permission setting workflow due to missing bot scope docs:permission.setting:read: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
|
||||
}
|
||||
}
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
if !gjson.Get(result.Stdout, "data.permission_public").Exists() {
|
||||
t.Fatalf("permission_public missing in output\nstdout:\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",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user