mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
27 Commits
v1.0.63
...
feat-svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca8efe5d92 | ||
|
|
5ae2594a5f | ||
|
|
fd96f6e895 | ||
|
|
81c36bcf85 | ||
|
|
283462a36f | ||
|
|
d4e074a494 | ||
|
|
15e7ab8b66 | ||
|
|
f043ee61d8 | ||
|
|
5b264cf7b2 | ||
|
|
ead6362ab6 | ||
|
|
9c0c5ae26a | ||
|
|
8a450b6437 | ||
|
|
e196f68ef6 | ||
|
|
dff21a86ec | ||
|
|
38bf5402d9 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 | ||
|
|
d0cde9a414 | ||
|
|
075b34f9a3 | ||
|
|
3788405256 |
@@ -0,0 +1,259 @@
|
||||
# `slides +create-svglide` Codex Runtime Design
|
||||
|
||||
Date: 2026-07-02
|
||||
Branch: `feat-svglide-07`
|
||||
Scope: first local-only version of `lark-cli slides +create-svglide`
|
||||
|
||||
## Result
|
||||
|
||||
Build `slides +create-svglide` as a staged local runtime for AnyGen SVG Slides. The command creates and manages a run directory that Codex can fill with generated content, assets, and SVG slides. The CLI owns state, prompts, schemas, validation, preview, receipts, and recovery. Codex owns LLM reasoning, web research, image/search execution, chart design, and SVG authoring.
|
||||
|
||||
The first version does not publish to Feishu Slides. It must produce a local, inspectable SVG deck workbench.
|
||||
|
||||
## Context
|
||||
|
||||
`feat-svglide-07` currently starts from the latest `origin/main` and has only the existing Slides XML shortcut surface. There is no current `+create-svglide` implementation on this branch.
|
||||
|
||||
The AnyGen SVG Slides prompt should be reused as contracts and workflow rules, not pasted as one large prompt. Its value is split across request interpretation, research, design brief, outline, `slide_content.md`, asset planning, SVG authoring, protocol validation, preview, and repair.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a staged `slides +create-svglide` command group.
|
||||
- Create a local run directory under a user-specified `--out` path, usually `.lark-slides/svglide-runs/<run-id>`.
|
||||
- Generate prompt task files that tell Codex exactly what to produce for each stage.
|
||||
- Generate JSON schemas for stage outputs.
|
||||
- Track stage state in `run.json`.
|
||||
- Validate JSON outputs, SVG protocol basics, asset href existence, slide count, placeholder slides, and preview generation.
|
||||
- Generate `preview.html` for local inspection.
|
||||
- Write receipts and `repair_queue.md` so failed runs can resume from the current stage.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No online Feishu Slides creation.
|
||||
- No `slide_engine` or `slide` server changes.
|
||||
- No SVG-to-SXSD conversion.
|
||||
- No built-in model API provider.
|
||||
- No built-in web search, image generation, or image search client.
|
||||
- No complete 12-agent process runner.
|
||||
- No PPTX import/edit workflow.
|
||||
|
||||
## Command Surface
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide init --title "Demo" --input ./source.md --audience "..." --delivery-mode self_read --pages 8 --out ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide next <run-dir>
|
||||
lark-cli slides +create-svglide status <run-dir>
|
||||
lark-cli slides +create-svglide validate <run-dir>
|
||||
lark-cli slides +create-svglide preview <run-dir>
|
||||
```
|
||||
|
||||
`init` creates the run directory, writes the initial request files, schemas, stage prompts, and `run.json`.
|
||||
|
||||
`next` reads `run.json`, finds the next stage, verifies required inputs, renders or refreshes that stage's Codex task prompt, and reports the exact files Codex must create. It must not pretend LLM work is complete.
|
||||
|
||||
`status` checks declared outputs and receipts for each stage, then prints the current stage, missing files, and next useful command.
|
||||
|
||||
`validate` runs deterministic checks and writes validation receipts.
|
||||
|
||||
`preview` writes `preview.html` from `outline/deck.json` and `slides/*.svg`.
|
||||
|
||||
## Run Directory Contract
|
||||
|
||||
```text
|
||||
<run-dir>/
|
||||
run.json
|
||||
README.md
|
||||
request/request.json
|
||||
request/source_manifest.json
|
||||
research/research_notes.md
|
||||
research/sources.json
|
||||
brief/design_brief.json
|
||||
brief/visual_system.json
|
||||
outline/deck.json
|
||||
content/slide_content.md
|
||||
content/slide_content.json
|
||||
assets/assets_plan.json
|
||||
assets/images/
|
||||
assets/charts/
|
||||
slides/*.svg
|
||||
prompts/*.task.md
|
||||
schemas/*.schema.json
|
||||
receipts/*.json
|
||||
receipts/generation_summary.md
|
||||
repair_queue.md
|
||||
preview.html
|
||||
```
|
||||
|
||||
The run directory is local agent state. It should not be committed by default.
|
||||
|
||||
## State Model
|
||||
|
||||
`run.json` stores:
|
||||
|
||||
- version
|
||||
- runtime, always `codex` in v1
|
||||
- command name
|
||||
- title
|
||||
- created and updated timestamps
|
||||
- current stage
|
||||
- stage list with status, inputs, outputs, and receipt path
|
||||
- important artifact paths
|
||||
- policy flags: `publish_enabled=false`, `network_by_codex=true`, `image_generation_by_codex=true`, `overwrite=false`
|
||||
|
||||
Stage statuses:
|
||||
|
||||
```text
|
||||
pending
|
||||
ready
|
||||
in_progress
|
||||
done
|
||||
failed
|
||||
blocked
|
||||
needs_repair
|
||||
```
|
||||
|
||||
## Stage Design
|
||||
|
||||
### 1. request
|
||||
|
||||
Role: Request Interpreter
|
||||
|
||||
Input: CLI flags and local source path.
|
||||
|
||||
Output: `request/request.json`, `request/source_manifest.json`.
|
||||
|
||||
Validation: title, audience, delivery mode, page count, and source references must be explicit or marked missing.
|
||||
|
||||
### 2. research
|
||||
|
||||
Role: Researcher
|
||||
|
||||
Input: request files and source files.
|
||||
|
||||
Output: `research/research_notes.md`, `research/sources.json`.
|
||||
|
||||
Validation: key facts need source references. Codex may perform web research, but the CLI only validates resulting files.
|
||||
|
||||
### 3. design_brief
|
||||
|
||||
Role: Design Brief Resolver and Visual System Planner
|
||||
|
||||
Input: request and research outputs.
|
||||
|
||||
Output: `brief/design_brief.json`, `brief/visual_system.json`.
|
||||
|
||||
Validation: narrative spine, depth, tone, and visual system dimensions must be present.
|
||||
|
||||
### 4. outline
|
||||
|
||||
Role: Outline Planner
|
||||
|
||||
Input: design brief.
|
||||
|
||||
Output: `outline/deck.json`.
|
||||
|
||||
Validation: page count matches request; each slide has id, title, summary, role, and key message.
|
||||
|
||||
### 5. slide_content
|
||||
|
||||
Role: Content Builder
|
||||
|
||||
Input: deck outline and research notes.
|
||||
|
||||
Output: `content/slide_content.md`, `content/slide_content.json`.
|
||||
|
||||
Validation: every slide has key material, content blocks, and source notes. This is content planning, not final layout.
|
||||
|
||||
### 6. assets
|
||||
|
||||
Role: Asset Planner and Chart Generator
|
||||
|
||||
Input: slide content and visual system.
|
||||
|
||||
Output: `assets/assets_plan.json`, optional `assets/images/*`, optional `assets/charts/*.svg`.
|
||||
|
||||
Validation: every planned asset has purpose plus either a local path or a fallback. Chart takeaway must be written before chart type.
|
||||
|
||||
### 7. svg_author
|
||||
|
||||
Role: SVG Author
|
||||
|
||||
Input: deck, slide content, visual system, and assets.
|
||||
|
||||
Output: `slides/*.svg`.
|
||||
|
||||
Validation: each slide must contain more than a background. Each slide needs a background, title, visible content or visual element, semantic id, and valid SVG root.
|
||||
|
||||
### 8. validate_preview_repair
|
||||
|
||||
Role: Protocol Validator, Preview Agent, and Repair Agent
|
||||
|
||||
Input: generated slides.
|
||||
|
||||
Output: `receipts/lint.json`, `receipts/preview.json`, `repair_queue.md`, `preview.html`.
|
||||
|
||||
Validation: SVG protocol lint, local href checks, slide count match, preview write success, and unresolved issues recorded in the repair queue.
|
||||
|
||||
## Code Layout
|
||||
|
||||
```text
|
||||
shortcuts/slides/
|
||||
slides_create_svglide.go
|
||||
slides_create_svglide_test.go
|
||||
|
||||
internal/svglide/
|
||||
run.go
|
||||
init.go
|
||||
stage.go
|
||||
prompt.go
|
||||
schema.go
|
||||
validate.go
|
||||
preview.go
|
||||
receipt.go
|
||||
```
|
||||
|
||||
The shortcut package should stay thin. State, prompt rendering, validation, and preview logic belong in `internal/svglide` so they can be tested without a Cobra/runtime-heavy command harness.
|
||||
|
||||
## Skill Documentation
|
||||
|
||||
Update `skills/lark-slides/SKILL.md` and add a focused reference file for the local SVG runtime. The skill should explain that `+create-svglide` is local-only in v1, requires Codex to fill stage outputs, and must not be described as an online publish path.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing required inputs block the stage and write a receipt.
|
||||
- Invalid JSON or schema mismatch marks the stage failed.
|
||||
- Invalid SVG marks `needs_repair` and writes `repair_queue.md`.
|
||||
- Existing output paths are not overwritten unless an explicit overwrite policy is enabled.
|
||||
- Partially completed stages remain inspectable; reruns resume from the current stage.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `init` creates the expected directory tree and `run.json`.
|
||||
- `init` refuses to overwrite an existing run directory by default.
|
||||
- `status` identifies missing outputs.
|
||||
- `next` renders the correct stage prompt and does not mark Codex-only stages done.
|
||||
- `validate` catches invalid SVG, missing hrefs, placeholder slides, and slide count mismatch.
|
||||
- `preview` writes HTML that references generated SVG files.
|
||||
|
||||
Fixtures:
|
||||
|
||||
- `testdata/svglide_run_valid/`
|
||||
- `testdata/svglide_run_invalid/`
|
||||
|
||||
No live end-to-end test is required for v1 because this version does not call Feishu APIs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A user can initialize a run directory from local input.
|
||||
- Codex can follow generated task prompts stage by stage.
|
||||
- The CLI can report status and missing artifacts.
|
||||
- The CLI can validate a completed local SVG deck.
|
||||
- The CLI can generate local preview HTML.
|
||||
- Failed validation produces actionable repair output.
|
||||
- No online presentation is created.
|
||||
|
||||
## Further Judgment
|
||||
|
||||
This design deliberately optimizes for artifact contracts rather than agent-count symmetry. Once the local runtime is stable, individual stages can be split into fuller agents without changing the run directory contract.
|
||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||
WithMissingScopes("mail:user_mailbox.message:send").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||
WithMissingScopes("calendar:event:create").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"identity": "user",
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||
"missing_scopes": []any{"calendar:event:create"},
|
||||
}
|
||||
for k, want := range wantFields {
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||
// coupling this contract to core's brand enum.
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
// QueryEscape both values — clientID and scopes both sit in the query
|
||||
// string, and untrusted content must not be able to inject extra query
|
||||
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||
// open-platform base URL stays a single source of truth.
|
||||
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
|
||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
wantInURL: []string{"scopes=scope%23fragment"},
|
||||
denyInURL: []string{"scopes=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||
denyInURL: []string{"clientID=good?q=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
wantInURL: []string{"clientID=good%23fragment"},
|
||||
denyInURL: []string{"clientID=good#fragment"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
name: "slash in appID does not open a new path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
||||
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||
if !strings.Contains(got, "developer console") {
|
||||
|
||||
@@ -10,15 +10,20 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
// Secure label endpoint codes observed from drive +secure-label-update
|
||||
// failure telemetry.
|
||||
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
|
||||
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -78,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
if isPlaceholderCredentialURL(file, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
if !warnForPrivateIPv4(file) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
@@ -130,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
@@ -284,6 +291,9 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
@@ -313,11 +323,109 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
value = strings.TrimPrefix(value, "$")
|
||||
value = strings.Trim(value, "%")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func highEntropyCredentialValue(value string) bool {
|
||||
if len(value) < 32 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-' || r == '.' || r == '=':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
|
||||
}
|
||||
|
||||
func shannonEntropy(value string) float64 {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
counts := map[rune]int{}
|
||||
for _, r := range value {
|
||||
counts[r]++
|
||||
}
|
||||
var entropy float64
|
||||
length := float64(len([]rune(value)))
|
||||
for _, count := range counts {
|
||||
p := float64(count) / length
|
||||
entropy -= p * log2(p)
|
||||
}
|
||||
return entropy
|
||||
}
|
||||
|
||||
func log2(value float64) float64 {
|
||||
return math.Log(value) / math.Ln2
|
||||
}
|
||||
|
||||
func authCredentialTokenKey(key string) bool {
|
||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||
case "access_token",
|
||||
"api_token",
|
||||
"bot_token",
|
||||
"refresh_token",
|
||||
"secret_token",
|
||||
"session_token",
|
||||
"service_token",
|
||||
"bearer_token",
|
||||
"auth_token",
|
||||
"authorization_token",
|
||||
@@ -844,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -853,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
return credentialURLPasswordPlaceholder(password) ||
|
||||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
@@ -867,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
"pw",
|
||||
"s3cret",
|
||||
"secret",
|
||||
"t":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceOrTestFixtureFile(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
return sourceCodeFile(normalized) ||
|
||||
strings.HasPrefix(normalized, "testdata/") ||
|
||||
strings.HasPrefix(normalized, "fixtures/") ||
|
||||
strings.Contains(normalized, "/testdata/") ||
|
||||
strings.Contains(normalized, "/fixtures/")
|
||||
}
|
||||
|
||||
func warnForPrivateIPv4(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
if sourceOrTestFixtureFile(normalized) {
|
||||
return false
|
||||
}
|
||||
switch filepath.Ext(normalized) {
|
||||
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(normalized, "docs/") ||
|
||||
strings.HasPrefix(normalized, "skills/")
|
||||
}
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
|
||||
@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
|
||||
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@10.0.0.1:3128"`,
|
||||
`target := "socks5://admin:secret@172.16.0.1:1080"`,
|
||||
`host := "192.168.0.10"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
||||
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||
if len(benign) != 0 {
|
||||
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
|
||||
`proxy: http://user:pass@proxy:8080`,
|
||||
`repo: https://u:t@h/r.git`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
|
||||
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
|
||||
`endpoint: http://10.0.0.1:8080`,
|
||||
`redis: 192.168.1.10:6379`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||
for _, item := range got {
|
||||
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||
"URL=https://<user>:real-secret@example.invalid/path",
|
||||
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
|
||||
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
|
||||
`{"token":"img_abc123"}`,
|
||||
`{"token":"img_live_secret"}`,
|
||||
`{"token":"img_prod_key"}`,
|
||||
`token=ab********cd`,
|
||||
`{"image_token":"img_live_secret"}`,
|
||||
`{"data_mail_token":"mail_abc123"}`,
|
||||
`{"whiteboard_token":"board_v3_example"}`,
|
||||
`{"want_token":"token from callback"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
stripeToken := "sk_" + "live_1234567890abcdef"
|
||||
randomToken := strings.Join([]string{
|
||||
"a1b2c3d4",
|
||||
"e5f6g7h8",
|
||||
"i9j0k1l2",
|
||||
"m3n4p5q6",
|
||||
}, "")
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"token":"` + githubToken + `"}`,
|
||||
`token=` + stripeToken,
|
||||
`{"image_token":"` + githubToken + `"}`,
|
||||
`{"token":"` + randomToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
@@ -1052,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
||||
if appID == "" || scope == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
core.ResolveOpenBaseURL(brand),
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
|
||||
348
internal/svglide/author.go
Normal file
348
internal/svglide/author.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSlideWidth = 960
|
||||
defaultSlideHeight = 540
|
||||
defaultAuthorBgColor = "#FFFFFF"
|
||||
defaultAuthorInkColor = "#111827"
|
||||
defaultAuthorMuteColor = "#6B7280"
|
||||
defaultAuthorAccent = "#2563EB"
|
||||
svgAuthorReceipt = "receipts/svg_author.json"
|
||||
)
|
||||
|
||||
type AuthorReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []string `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []authorDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type authorSlideContentFile struct {
|
||||
Slides []authorSlideContent `json:"slides"`
|
||||
}
|
||||
|
||||
type authorSlideContent struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type authorVisualSystem struct {
|
||||
ColorSystem struct {
|
||||
Background string `json:"background"`
|
||||
Ink string `json:"ink"`
|
||||
Muted string `json:"muted"`
|
||||
Accent string `json:"accent"`
|
||||
} `json:"color_system"`
|
||||
Typography struct {
|
||||
Title int `json:"title"`
|
||||
Body int `json:"body"`
|
||||
} `json:"typography"`
|
||||
LayoutLanguage string `json:"layout_language"`
|
||||
}
|
||||
|
||||
type authorTheme struct {
|
||||
Background string
|
||||
Ink string
|
||||
Muted string
|
||||
Accent string
|
||||
TitleSize int
|
||||
BodySize int
|
||||
}
|
||||
|
||||
type authorSlideTarget struct {
|
||||
Slide authorDeckSlide
|
||||
Content authorSlideContent
|
||||
Path string
|
||||
Target string
|
||||
Page int
|
||||
}
|
||||
|
||||
func AuthorSlides(root string) (AuthorReport, error) {
|
||||
return authorSlides(root, nil)
|
||||
}
|
||||
|
||||
func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
deck, err := readAuthorDeck(safeRoot, strings.TrimSpace(run.Artifacts.Deck))
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
contentByID, err := readAuthorContent(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
theme, err := readAuthorTheme(safeRoot, "brief/visual_system.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := readAuthorJSONContract(safeRoot, "assets/assets_plan.json"); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := validateAuthorDeckContent(deck, contentByID); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
targets := make([]authorSlideTarget, 0, len(deck.Slides))
|
||||
report := AuthorReport{
|
||||
Status: StatusDone,
|
||||
Slides: make([]string, 0, len(deck.Slides)),
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if selectedPaths != nil && !selectedPaths[slidePath] {
|
||||
continue
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
targets = append(targets, authorSlideTarget{
|
||||
Slide: slide,
|
||||
Content: contentByID[strings.TrimSpace(slide.ID)],
|
||||
Path: slidePath,
|
||||
Target: target,
|
||||
Page: i + 1,
|
||||
})
|
||||
report.Slides = append(report.Slides, slidePath)
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, svgAuthorReceipt)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, theme, target.Page, len(deck.Slides))
|
||||
if err := writeText(target.Target, svg); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
}
|
||||
if err := writeJSON(receiptTarget, StageReceipt{
|
||||
Stage: StageSVGAuthor,
|
||||
Status: StatusDone,
|
||||
Artifacts: report.Slides,
|
||||
}); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readAuthorDeck(safeRoot string, deckPath string) (authorDeck, error) {
|
||||
if deckPath == "" {
|
||||
return authorDeck{}, fmt.Errorf("deck artifact path is empty")
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
var deck authorDeck
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return authorDeck{}, fmt.Errorf("deck %q contains no slides", deckPath)
|
||||
}
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
func readAuthorContent(safeRoot string, path string) (map[string]authorSlideContent, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
var file authorSlideContentFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
byID := make(map[string]authorSlideContent, len(file.Slides))
|
||||
for _, slide := range file.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("slide content id must not be empty")
|
||||
}
|
||||
if _, exists := byID[id]; exists {
|
||||
return nil, fmt.Errorf("slide content id %q is duplicated", id)
|
||||
}
|
||||
byID[id] = slide
|
||||
}
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
func validateAuthorDeckContent(deck authorDeck, contentByID map[string]authorSlideContent) error {
|
||||
deckIDs := make(map[string]bool, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("deck slide id must not be empty")
|
||||
}
|
||||
if deckIDs[id] {
|
||||
return fmt.Errorf("deck slide id %q is duplicated", id)
|
||||
}
|
||||
deckIDs[id] = true
|
||||
if _, ok := contentByID[id]; !ok {
|
||||
return fmt.Errorf("deck slide id %q is missing from slide content", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAuthorTheme(safeRoot string, path string) (authorTheme, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
var visual authorVisualSystem
|
||||
if err := json.Unmarshal(raw, &visual); err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
theme := authorTheme{
|
||||
Background: normalizeAuthorColor(visual.ColorSystem.Background, defaultAuthorBgColor),
|
||||
Ink: normalizeAuthorColor(visual.ColorSystem.Ink, defaultAuthorInkColor),
|
||||
Muted: normalizeAuthorColor(visual.ColorSystem.Muted, defaultAuthorMuteColor),
|
||||
Accent: normalizeAuthorColor(visual.ColorSystem.Accent, defaultAuthorAccent),
|
||||
TitleSize: visual.Typography.Title,
|
||||
BodySize: visual.Typography.Body,
|
||||
}
|
||||
if theme.TitleSize <= 0 {
|
||||
theme.TitleSize = 32
|
||||
}
|
||||
if theme.BodySize <= 0 {
|
||||
theme.BodySize = 16
|
||||
}
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
func readAuthorJSONContract(safeRoot string, path string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read assets plan %q: %w", path, err)
|
||||
}
|
||||
var contract any
|
||||
if err := json.Unmarshal(raw, &contract); err != nil {
|
||||
return fmt.Errorf("read assets plan %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAuthorColor(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if isAllowedAuthorHexColor(value) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isAllowedAuthorHexColor(value string) bool {
|
||||
if len(value) != 4 && len(value) != 7 && len(value) != 9 {
|
||||
return false
|
||||
}
|
||||
if value[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[1:] {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, theme authorTheme, page int, total int) string {
|
||||
title := firstNonEmpty(slide.Title, "Untitled slide")
|
||||
keyMessage := firstNonEmpty(slide.KeyMessage, slide.Summary)
|
||||
bodyLines := authorBodyLines(content.Content)
|
||||
footer := strings.TrimSpace(deckTitle)
|
||||
if footer == "" {
|
||||
footer = "SVGlide"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="%s" xmlns:slide="%s" width="%d" height="%d" viewBox="0 0 960 540" slide:role="slide">`+"\n", svgNamespace, slideNamespace, defaultSlideWidth, defaultSlideHeight)
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="540" fill="%s" data-role="background"/>`+"\n", escapeAttr(theme.Background))
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="8" fill="%s"/>`+"\n", escapeAttr(theme.Accent))
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="48" width="848" height="404" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;">`+"\n", escapeAttr(theme.Ink))
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;font-weight:700;line-height:1.16;margin-bottom:16px;">%s</div>`+"\n", theme.TitleSize, escapeText(title))
|
||||
if keyMessage != "" {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.35;color:%s;margin-bottom:22px;">%s</div>`+"\n", maxInt(theme.BodySize+4, 18), escapeAttr(theme.Accent), escapeText(keyMessage))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <div style="border:1px solid #E5E7EB;border-radius:6px;padding:20px 24px;min-height:190px;background:#F9FAFB;">`+"\n")
|
||||
for _, line := range bodyLines {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.55;margin-bottom:8px;">- %s</div>`+"\n", theme.BodySize, escapeText(line))
|
||||
}
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="482" width="848" height="32" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;font-size:12px;display:flex;justify-content:space-between;">`+"\n", escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(&b, " <span>%s</span><span>%d / %d</span>\n", escapeText(footer), page, total)
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
fmt.Fprintf(&b, "</svg>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func authorBodyLines(content string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{"No content provided."}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeText(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func escapeAttr(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
180
internal/svglide/author_test.go
Normal file
180
internal/svglide/author_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note"},{"id":"s2","content":"Point A\nPoint B\nPoint C"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
report, err := AuthorSlides("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != StatusDone {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, StatusDone)
|
||||
}
|
||||
if len(report.Slides) != 2 {
|
||||
t.Fatalf("Slides len = %d, want 2: %+v", len(report.Slides), report.Slides)
|
||||
}
|
||||
receipt := readAuthorReceiptForTest(t)
|
||||
if receipt["stage"] != StageSVGAuthor {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageSVGAuthor)
|
||||
}
|
||||
if receipt["status"] != StatusDone {
|
||||
t.Fatalf("receipt status = %v, want %q", receipt["status"], StatusDone)
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
|
||||
for _, rel := range []string{"slides/01.svg", "slides/02.svg"} {
|
||||
raw, err := os.ReadFile(filepath.Join("demo", rel))
|
||||
if err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`slide:role="slide"`,
|
||||
`viewBox="0 0 960 540"`,
|
||||
`foreignObject`,
|
||||
`slide:shape-type="text"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("%s missing %q:\n%s", rel, want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesFallsBackForUnsafeColorTokens(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"url(https://example.com/bg.svg)","ink":"red;background:url(https://example.com/x)","muted":"not-a-color","accent":"#abc"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, banned := range []string{"url(", "https://example.com", "red;background", "not-a-color"} {
|
||||
if strings.Contains(svg, banned) {
|
||||
t.Fatalf("SVG contains unsafe color token %q:\n%s", banned, svg)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`fill="#FFFFFF"`, `color:#111827`, `color:#6B7280`, `fill="#abc"`, `color:#abc`} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("SVG missing normalized/default color %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesPreflightsSlidePathsBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/../02.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected invalid second slide path to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "01.svg")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("first slide output exists after preflight failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsMissingContentBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected missing slide content to fail")
|
||||
}
|
||||
for _, rel := range []string{"slides/01.svg", "receipts/svg_author.json"} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s exists after content preflight failure, stat err = %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"},{"id":"s1","content":"Duplicate body line"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected duplicate slide content id to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("svg_author receipt exists after duplicate content id failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func initAuthorDemoRun(t *testing.T, visualSystem string, deck string) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", visualSystem)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", deck)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note"},{"id":"s2","content":"Point A\nPoint B\nPoint C"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
|
||||
func readAuthorReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "svg_author.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func mustWriteTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
198
internal/svglide/init.go
Normal file
198
internal/svglide/init.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type InitOptions struct {
|
||||
Title string
|
||||
Input string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Now time.Time
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func InitRun(root string, opts InitOptions) error {
|
||||
root = strings.TrimSpace(root)
|
||||
opts.Title = strings.TrimSpace(opts.Title)
|
||||
opts.Input = strings.TrimSpace(opts.Input)
|
||||
if root == "" {
|
||||
return fmt.Errorf("out path is required")
|
||||
}
|
||||
if opts.Title == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if opts.Input == "" {
|
||||
return fmt.Errorf("input is required")
|
||||
}
|
||||
safeRoot, err := validate.SafeOutputPath(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRunRoot(root, safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
safeInput, err := validate.SafeInputPath(opts.Input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInputOutsideRunRoot(safeRoot, safeInput); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Input = safeInput
|
||||
|
||||
if opts.Overwrite {
|
||||
return initOverwrite(safeRoot, opts)
|
||||
}
|
||||
|
||||
return initNoReplace(safeRoot, opts)
|
||||
}
|
||||
|
||||
func validateRunRoot(root string, safeRoot string) error {
|
||||
if filepath.Clean(root) == "." {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
canonicalCwd, err := vfs.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve working directory: %w", err)
|
||||
}
|
||||
if filepath.Clean(safeRoot) == filepath.Clean(canonicalCwd) {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputOutsideRunRoot(safeRoot string, safeInput string) error {
|
||||
root := filepath.Clean(safeRoot)
|
||||
input := filepath.Clean(safeInput)
|
||||
rel, err := filepath.Rel(root, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot compare input and output paths: %w", err)
|
||||
}
|
||||
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
|
||||
return fmt.Errorf("input path %q must be outside output run directory %q", safeInput, safeRoot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initNoReplace(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("%s already exists or cannot be created; refusing to overwrite: %w", safeRoot, err)
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func initOverwrite(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.RemoveAll(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func writeClaimedRunDirectory(safeRoot string, opts InitOptions) error {
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = vfs.RemoveAll(safeRoot)
|
||||
}
|
||||
}()
|
||||
if err := writeRunDirectory(safeRoot, safeRoot, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRunDirectory(writeRoot string, runRoot string, opts InitOptions) error {
|
||||
for _, dir := range []string{
|
||||
"request",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"assets/images",
|
||||
"assets/charts",
|
||||
"slides",
|
||||
"prompts",
|
||||
"schemas",
|
||||
"receipts",
|
||||
} {
|
||||
if err := vfs.MkdirAll(filepath.Join(writeRoot, dir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: opts.Title,
|
||||
Input: opts.Input,
|
||||
Audience: opts.Audience,
|
||||
DeliveryMode: opts.DeliveryMode,
|
||||
Pages: opts.Pages,
|
||||
Out: runRoot,
|
||||
Now: opts.Now,
|
||||
})
|
||||
run.Policy.Overwrite = opts.Overwrite
|
||||
if err := writeJSON(filepath.Join(writeRoot, "run.json"), run); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "request.json"), map[string]any{
|
||||
"title": opts.Title,
|
||||
"input": opts.Input,
|
||||
"audience": opts.Audience,
|
||||
"delivery_mode": opts.DeliveryMode,
|
||||
"pages": opts.Pages,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "source_manifest.json"), map[string]any{
|
||||
"sources": []map[string]string{{"path": opts.Input, "type": "local"}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeStaticFiles(writeRoot)
|
||||
}
|
||||
|
||||
func writeStaticFiles(root string) error {
|
||||
if err := writeText(filepath.Join(root, "README.md"), renderRunREADME()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, prompt := range DefaultPromptFiles() {
|
||||
if err := writeText(filepath.Join(root, "prompts", prompt.Name), prompt.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for name, schema := range DefaultSchemas() {
|
||||
if err := writeText(filepath.Join(root, "schemas", name), schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderRunREADME() string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Local Run\n\n")
|
||||
b.WriteString("This directory is a local Codex-mediated SVG slides runtime. It does not publish to Feishu Slides.\n")
|
||||
return b.String()
|
||||
}
|
||||
369
internal/svglide/init_test.go
Normal file
369
internal/svglide/init_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitRunWritesDirectoryContract(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
canonicalCwd, err := filepath.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "demo"
|
||||
wantInput := filepath.Join(canonicalCwd, "source.md")
|
||||
err = InitRun(root, InitOptions{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Now: time.Date(2026, 7, 2, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"run.json",
|
||||
"README.md",
|
||||
"request/request.json",
|
||||
"request/source_manifest.json",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"prompts/01_request.task.md",
|
||||
"prompts/07_svg_author.task.md",
|
||||
"schemas/request.schema.json",
|
||||
"schemas/deck.schema.json",
|
||||
"receipts",
|
||||
"slides",
|
||||
"assets/charts",
|
||||
"assets/images",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
|
||||
t.Fatalf("missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
if run.Input != wantInput {
|
||||
t.Fatalf("run.Input = %q, want %q", run.Input, wantInput)
|
||||
}
|
||||
|
||||
requestRaw, err := os.ReadFile(filepath.Join(root, "request", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(requestRaw, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request["title"] != "Demo" || request["input"] != wantInput || request["audience"] != "产品负责人" || request["delivery_mode"] != "self_read" || request["pages"] != float64(8) {
|
||||
t.Fatalf("unexpected request.json: %+v", request)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(root, "request", "source_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Sources []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest.Sources) != 1 || manifest.Sources[0].Path != wantInput || manifest.Sources[0].Type != "local" {
|
||||
t.Fatalf("unexpected source_manifest.json: %+v", manifest)
|
||||
}
|
||||
|
||||
promptRaw, err := os.ReadFile(filepath.Join(root, "prompts", "07_svg_author.task.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := string(promptRaw)
|
||||
for _, want := range []string{"Inputs", "Outputs", "Receipt", "禁止只写背景"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("svg author prompt missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
schemaRaw, err := os.ReadFile(filepath.Join(root, "schemas", "deck.schema.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deckSchema map[string]any
|
||||
if err := json.Unmarshal(schemaRaw, &deckSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := deckSchema["properties"]; !ok || !strings.Contains(string(schemaRaw), "key_message") {
|
||||
t.Fatalf("deck schema missing properties/key_message: %s", string(schemaRaw))
|
||||
}
|
||||
if !strings.Contains(string(schemaRaw), `"minItems": 1`) || !strings.Contains(string(schemaRaw), `^slides/[^/]+\\.svg$`) {
|
||||
t.Fatalf("deck schema missing minItems/path pattern: %s", string(schemaRaw))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"source_manifest.schema.json",
|
||||
"sources.schema.json",
|
||||
"receipt.schema.json",
|
||||
"lint.schema.json",
|
||||
"preview.schema.json",
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", name, err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("schema %s is not valid JSON: %v", name, err)
|
||||
}
|
||||
if schema["type"] == nil {
|
||||
t.Fatalf("schema %s missing type: %s", name, string(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "run.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRootWithoutRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantREADME := "keep this readme\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(wantREADME), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
gotREADME, readErr := os.ReadFile(filepath.Join(root, "README.md"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(gotREADME) != wantREADME {
|
||||
t.Fatalf("README overwritten: got %q, want %q", string(gotREADME), wantREADME)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected existing root refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunOverwriteReplacesOldRunDirectory(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(filepath.Join(root, "slides"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "slides", "old.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "slides", "old.svg")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old slide should be removed, stat err = %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !run.Policy.Overwrite {
|
||||
t.Fatalf("Policy.Overwrite = false, want true: %+v", run.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsOverlappingInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
input string
|
||||
overwrite bool
|
||||
}{
|
||||
{name: "same path overwrite", root: "source.md", input: "source.md", overwrite: true},
|
||||
{name: "input under output overwrite", root: "demo", input: "demo/source.md", overwrite: true},
|
||||
{name: "input under output no overwrite", root: "demo", input: "demo/source.md", overwrite: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.MkdirAll(filepath.Dir(tt.input), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(tt.input, []byte("source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(tt.root, InitOptions{Title: "Demo", Input: tt.input, Overwrite: tt.overwrite})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping input/output refusal")
|
||||
}
|
||||
got, readErr := os.ReadFile(tt.input)
|
||||
if readErr != nil {
|
||||
t.Fatalf("source should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "source" {
|
||||
t.Fatalf("source content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsUnsafePaths(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "absolute root", root: filepath.Join(cwd, "demo"), opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping root", root: "../escape", opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping input", root: "demo", opts: InitOptions{Title: "Demo", Input: "../source.md"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected unsafe path refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsRootResolvingToCWDWhenOverwrite(t *testing.T) {
|
||||
for _, root := range []string{".", "./", "subdir/.."} {
|
||||
t.Run(root, func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
markerPath := filepath.Join(cwd, "keep.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err == nil {
|
||||
t.Fatal("expected root resolving to CWD to be rejected")
|
||||
}
|
||||
got, readErr := os.ReadFile(markerPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("marker should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "keep" {
|
||||
t.Fatalf("marker content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPromptFilesValidatePreviewRepairMatchesStageContract(t *testing.T) {
|
||||
var repairPrompt string
|
||||
for _, prompt := range DefaultPromptFiles() {
|
||||
if prompt.Name == "08_repair.task.md" {
|
||||
repairPrompt = prompt.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if repairPrompt == "" {
|
||||
t.Fatal("missing 08_repair.task.md")
|
||||
}
|
||||
wantOutputs := []string{
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"repair_queue.md",
|
||||
"preview.html",
|
||||
}
|
||||
if got := promptSectionItems(repairPrompt, "Outputs"); !sameStrings(got, wantOutputs) {
|
||||
t.Fatalf("08_repair Outputs = %v, want %v\n%s", got, wantOutputs, repairPrompt)
|
||||
}
|
||||
if got := promptSectionItems(repairPrompt, "Receipt"); !sameStrings(got, []string{"receipts/validate_preview_repair.json"}) {
|
||||
t.Fatalf("08_repair Receipt = %v, want receipts/validate_preview_repair.json\n%s", got, repairPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func promptSectionItems(content string, section string) []string {
|
||||
lines := strings.Split(content, "\n")
|
||||
inSection := false
|
||||
var items []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == section+":" {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if inSection && strings.HasSuffix(trimmed, ":") {
|
||||
break
|
||||
}
|
||||
if inSection && strings.HasPrefix(trimmed, "- ") {
|
||||
items = append(items, strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func sameStrings(got []string, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestInitRunRejectsBlankRequiredFields(t *testing.T) {
|
||||
blankRoot := " "
|
||||
t.Chdir(t.TempDir())
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "root", root: blankRoot, opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "title", root: "title", opts: InitOptions{Title: " \t", Input: "source.md"}},
|
||||
{name: "input", root: "input", opts: InitOptions{Title: "Demo", Input: " \t"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected blank field refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
internal/svglide/io.go
Normal file
20
internal/svglide/io.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(path, raw, 0o644)
|
||||
}
|
||||
|
||||
func writeText(path string, content string) error {
|
||||
return validate.AtomicWrite(path, []byte(content), 0o644)
|
||||
}
|
||||
431
internal/svglide/preview.go
Normal file
431
internal/svglide/preview.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const defaultPreviewPath = "preview.html"
|
||||
const previewReceiptPath = "receipts/preview.json"
|
||||
|
||||
type PreviewReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []PreviewSlideReport `json:"slides"`
|
||||
}
|
||||
|
||||
type PreviewSlideReport struct {
|
||||
Path string `json:"path"`
|
||||
Rendered bool `json:"rendered"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type previewDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []previewDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type previewDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type previewPageData struct {
|
||||
Title string
|
||||
Status string
|
||||
SlideCount int
|
||||
RenderedCount int
|
||||
Slides []previewPageSlide
|
||||
}
|
||||
|
||||
type previewPageSlide struct {
|
||||
Number int
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Role string
|
||||
KeyMessage string
|
||||
Path string
|
||||
Rendered bool
|
||||
Message string
|
||||
}
|
||||
|
||||
func WritePreview(root string) (PreviewReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return PreviewReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return writeFailedPreview(safeRoot, run, "", "deck artifact path is empty")
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q: %v", deckPath, err))
|
||||
}
|
||||
var deck previewDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := PreviewReport{Slides: make([]PreviewSlideReport, 0, len(deck.Slides))}
|
||||
pageSlides := make([]previewPageSlide, 0, len(deck.Slides))
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, pathErr := previewSlideObjectPath(slide.Path)
|
||||
pageSlide := previewPageSlide{
|
||||
Number: i + 1,
|
||||
ID: strings.TrimSpace(slide.ID),
|
||||
Title: strings.TrimSpace(slide.Title),
|
||||
Summary: strings.TrimSpace(slide.Summary),
|
||||
Role: strings.TrimSpace(slide.Role),
|
||||
KeyMessage: strings.TrimSpace(slide.KeyMessage),
|
||||
Path: slidePath,
|
||||
}
|
||||
item := PreviewSlideReport{Path: slidePath}
|
||||
if pathErr != nil {
|
||||
item.Message = pathErr.Error()
|
||||
} else if slidePath == "" {
|
||||
item.Path = "(slide)"
|
||||
pageSlide.Path = item.Path
|
||||
item.Message = "slide path must not be empty"
|
||||
} else if _, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
|
||||
item.Message = err.Error()
|
||||
} else {
|
||||
item.Rendered = true
|
||||
pageSlide.Rendered = true
|
||||
}
|
||||
pageSlide.Message = item.Message
|
||||
report.Slides = append(report.Slides, item)
|
||||
pageSlides = append(pageSlides, pageSlide)
|
||||
}
|
||||
report = normalizePreviewReport(report)
|
||||
|
||||
if err := writePreviewArtifacts(safeRoot, run, deck.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeFailedPreview(safeRoot string, run Run, path string, message string) (PreviewReport, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
path = "(deck)"
|
||||
}
|
||||
report := normalizePreviewReport(PreviewReport{
|
||||
Slides: []PreviewSlideReport{{
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}},
|
||||
})
|
||||
pageSlides := []previewPageSlide{{
|
||||
Number: 1,
|
||||
Title: "Preview failed",
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}}
|
||||
if err := writePreviewArtifacts(safeRoot, run, run.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func normalizePreviewReport(report PreviewReport) PreviewReport {
|
||||
if report.Slides == nil {
|
||||
report.Slides = []PreviewSlideReport{}
|
||||
}
|
||||
report.Status = "passed"
|
||||
for i := range report.Slides {
|
||||
report.Slides[i].Path = strings.TrimSpace(report.Slides[i].Path)
|
||||
if report.Slides[i].Path == "" {
|
||||
report.Slides[i].Path = "(slide)"
|
||||
}
|
||||
if !report.Slides[i].Rendered {
|
||||
report.Status = "failed"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func previewSlideObjectPath(path string) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("slide path must not be empty")
|
||||
}
|
||||
if strings.Contains(raw, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(raw, "%") {
|
||||
return "", fmt.Errorf("slide path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(raw, ":") || strings.Contains(raw, "//") {
|
||||
return "", fmt.Errorf("slide path %q must be a local slides/*.svg path", raw)
|
||||
}
|
||||
parts := strings.Split(raw, "/")
|
||||
if len(parts) != 2 || parts[0] != "slides" {
|
||||
return "", fmt.Errorf("slide path %q must match slides/<file>.svg", raw)
|
||||
}
|
||||
fileName := parts[1]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("slide path %q must include a slide file name", raw)
|
||||
}
|
||||
if strings.Contains(fileName, "/") || strings.Contains(fileName, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must not contain nested directories", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("slide path %q must not contain dot segments", raw)
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(fileName)) != ".svg" {
|
||||
return "", fmt.Errorf("slide path %q must end with .svg", raw)
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(raw))
|
||||
if cleaned != raw {
|
||||
return "", fmt.Errorf("slide path %q must already be normalized", raw)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writePreviewArtifacts(safeRoot string, run Run, title string, report PreviewReport, slides []previewPageSlide) error {
|
||||
report = normalizePreviewReport(report)
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
htmlRaw, err := renderPreviewHTML(title, report, slides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(target, htmlRaw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, previewReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(receiptTarget, raw, 0o644)
|
||||
}
|
||||
|
||||
func renderPreviewHTML(title string, report PreviewReport, slides []previewPageSlide) ([]byte, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "SVGlide Preview"
|
||||
}
|
||||
var rendered int
|
||||
for _, slide := range slides {
|
||||
if slide.Rendered {
|
||||
rendered++
|
||||
}
|
||||
}
|
||||
data := previewPageData{
|
||||
Title: title,
|
||||
Status: report.Status,
|
||||
SlideCount: len(slides),
|
||||
RenderedCount: rendered,
|
||||
Slides: slides,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := previewTemplate.Execute(&b, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
var previewTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} - SVGlide Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--ink: #1f2933;
|
||||
--muted: #657286;
|
||||
--line: #d8dee8;
|
||||
--accent: #1d7a62;
|
||||
--warn: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.status.failed { background: var(--warn); }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.deck {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.slide {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 24px rgba(31,41,51,.06);
|
||||
}
|
||||
.frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
object {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.missing {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
color: var(--warn);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.details {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.details h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.label {
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
}
|
||||
.path {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.message { color: var(--warn); overflow-wrap: anywhere; }
|
||||
@media (max-width: 860px) {
|
||||
.bar { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.meta { flex-wrap: wrap; white-space: normal; }
|
||||
.slide { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="bar">
|
||||
<h1>{{.Title}}</h1>
|
||||
<div class="meta">
|
||||
<span class="status {{.Status}}">{{.Status}}</span>
|
||||
<span>{{.RenderedCount}} / {{.SlideCount}} rendered</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="deck">
|
||||
{{range .Slides}}
|
||||
<article class="slide">
|
||||
<div class="frame">
|
||||
{{if .Rendered}}
|
||||
<object data="{{.Path}}" type="image/svg+xml" aria-label="{{.Title}}"></object>
|
||||
{{else}}
|
||||
<div class="missing">{{.Message}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{printf "%02d" .Number}}. {{.Title}}</h2>
|
||||
{{if .Summary}}<div><span class="label">Summary</span><br>{{.Summary}}</div>{{end}}
|
||||
{{if .KeyMessage}}<div><span class="label">Key Message</span><br>{{.KeyMessage}}</div>{{end}}
|
||||
{{if .Role}}<div><span class="label">Role</span><br>{{.Role}}</div>{{end}}
|
||||
<div><span class="label">Path</span><br><span class="path">{{.Path}}</span></div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
299
internal/svglide/preview_test.go
Normal file
299
internal/svglide/preview_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePreviewWritesHTMLAndReceipt(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || !report.Slides[0].Rendered || report.Slides[0].Path != "slides/01.svg" {
|
||||
t.Fatalf("Slides = %+v, want rendered slides/01.svg", report.Slides)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
for _, want := range []string{"Demo - SVGlide Preview", `data="slides/01.svg"`, "01. Slide", "Key Message"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("preview.html missing %q:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "passed" || len(receipt.Slides) != 1 || !receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want passed rendered slide", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewEscapesDeckText(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDeckAt(t, filepath.Join("demo", "outline", "deck.json"), previewDeck{
|
||||
Title: `<Deck & Demo>`,
|
||||
Slides: []previewDeckSlide{{
|
||||
ID: "cover",
|
||||
Title: `<Cover & One>`,
|
||||
Summary: `Summary <script>bad()</script>`,
|
||||
Role: "cover",
|
||||
KeyMessage: `Message & context`,
|
||||
Path: "slides/01.svg",
|
||||
}},
|
||||
})
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
if _, err := WritePreview("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
if strings.Contains(html, "<script>bad()</script>") {
|
||||
t.Fatalf("preview.html contains unescaped script:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<script>bad()</script>") || !strings.Contains(html, "<Deck & Demo>") {
|
||||
t.Fatalf("preview.html missing escaped deck text:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewUsesRunArtifactDeckAndPreviewPath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
run.Artifacts.Preview = "public/deck.html"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/missing.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "public", "deck.html")); err != nil {
|
||||
t.Fatalf("missing custom preview path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); !os.IsNotExist(err) {
|
||||
t.Fatalf("default preview should not be written when artifact path is custom, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewReportsUnsafeSlidePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slidePath string
|
||||
filePath string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "escape",
|
||||
slidePath: "../outside.svg",
|
||||
filePath: "outside.svg",
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "remote scheme",
|
||||
slidePath: "https:/evil.example/a.svg",
|
||||
filePath: filepath.Join("demo", "https:", "evil.example", "a.svg"),
|
||||
wantMessage: "local slides/*.svg",
|
||||
},
|
||||
{
|
||||
name: "encoded dot segment",
|
||||
slidePath: "slides/%2e%2e.svg",
|
||||
filePath: filepath.Join("demo", "slides", "%2e%2e.svg"),
|
||||
wantMessage: "percent encoding",
|
||||
},
|
||||
{
|
||||
name: "nested directory",
|
||||
slidePath: "slides/nested/01.svg",
|
||||
filePath: filepath.Join("demo", "slides", "nested", "01.svg"),
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "backslash",
|
||||
slidePath: `slides\01.svg`,
|
||||
filePath: filepath.Join("demo", `slides\01.svg`),
|
||||
wantMessage: "forward slashes",
|
||||
},
|
||||
{
|
||||
name: "wrong extension",
|
||||
slidePath: "slides/01.png",
|
||||
filePath: filepath.Join("demo", "slides", "01.png"),
|
||||
wantMessage: ".svg",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.slidePath)
|
||||
writeValidateTestFile(t, tt.filePath, visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || report.Slides[0].Rendered {
|
||||
t.Fatalf("Slides = %+v, want unrendered slide", report.Slides)
|
||||
}
|
||||
if !strings.Contains(report.Slides[0].Message, tt.wantMessage) {
|
||||
t.Fatalf("Message = %q, want %q", report.Slides[0].Message, tt.wantMessage)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want failed unrendered slide", receipt)
|
||||
}
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(htmlRaw), `data="`) {
|
||||
t.Fatalf("preview should not embed unsafe slide path:\n%s", string(htmlRaw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewWritesFailureArtifactsForDeckReadFailures(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html for failed deck read: %v", err)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Path != "outline/deck.json" {
|
||||
t.Fatalf("preview receipt = %+v, want failed deck report", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.html")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "preview.html")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "preview.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipt symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview receipt overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptsDirectorySymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipts directory symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "preview.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("preview receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readPreviewReceipt(t *testing.T) PreviewReport {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "preview.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt PreviewReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func writeDeckAt(t *testing.T, path string, deck previewDeck) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
390
internal/svglide/prompt.go
Normal file
390
internal/svglide/prompt.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package svglide
|
||||
|
||||
type PromptFile struct {
|
||||
Name string
|
||||
Content string
|
||||
}
|
||||
|
||||
func DefaultPromptFiles() []PromptFile {
|
||||
return []PromptFile{
|
||||
{Name: "01_request.task.md", Content: `# request
|
||||
|
||||
Inputs:
|
||||
- request/request.json
|
||||
- request/source_manifest.json
|
||||
|
||||
Outputs:
|
||||
- request/request.json
|
||||
- request/source_manifest.json
|
||||
|
||||
Receipt:
|
||||
- receipts/request.json
|
||||
|
||||
Acceptance:
|
||||
- title/input 必须存在且与本地源一致。
|
||||
- source_manifest.sources 必须列出本地输入源路径和 type=local。
|
||||
|
||||
Do not:
|
||||
- 不要联网发布或修改 slides 目录。
|
||||
`},
|
||||
{Name: "02_research.task.md", Content: `# research
|
||||
|
||||
Inputs:
|
||||
- request/request.json
|
||||
- request/source_manifest.json
|
||||
- local input source
|
||||
|
||||
Outputs:
|
||||
- research/research_notes.md
|
||||
- research/sources.json
|
||||
|
||||
Receipt:
|
||||
- receipts/research.json
|
||||
|
||||
Acceptance:
|
||||
- 结论必须能追溯到本地输入源。
|
||||
- sources.json 必须保留来源路径和摘录用途。
|
||||
|
||||
Do not:
|
||||
- 不要编造外部来源。
|
||||
`},
|
||||
{Name: "03_design_brief.task.md", Content: `# design_brief
|
||||
|
||||
Inputs:
|
||||
- request/request.json
|
||||
- research/research_notes.md
|
||||
|
||||
Outputs:
|
||||
- brief/design_brief.json
|
||||
- brief/visual_system.json
|
||||
|
||||
Receipt:
|
||||
- receipts/design_brief.json
|
||||
|
||||
Acceptance:
|
||||
- design_brief.json 必须包含 narrative_spine/depth/tone。
|
||||
- visual_system.json 必须包含 color_system/typography/layout_language。
|
||||
|
||||
Do not:
|
||||
- 不要提前生成最终 SVG。
|
||||
`},
|
||||
{Name: "04_outline.task.md", Content: `# outline
|
||||
|
||||
Inputs:
|
||||
- brief/design_brief.json
|
||||
- brief/visual_system.json
|
||||
|
||||
Outputs:
|
||||
- outline/deck.json
|
||||
|
||||
Receipt:
|
||||
- receipts/outline.json
|
||||
|
||||
Acceptance:
|
||||
- 每页必须有 id/title/summary/role/key_message/path。
|
||||
- deck.slides 顺序就是最终页序。
|
||||
|
||||
Do not:
|
||||
- 不要把正文长文塞进 outline。
|
||||
`},
|
||||
{Name: "05_slide_content.task.md", Content: `# slide_content
|
||||
|
||||
Inputs:
|
||||
- outline/deck.json
|
||||
- research/research_notes.md
|
||||
|
||||
Outputs:
|
||||
- content/slide_content.md
|
||||
- content/slide_content.json
|
||||
|
||||
Receipt:
|
||||
- receipts/slide_content.json
|
||||
|
||||
Acceptance:
|
||||
- slide_content.json 必须按 deck.slides 的 id 对齐。
|
||||
- 每页内容必须支持对应 key_message。
|
||||
|
||||
Do not:
|
||||
- 不要写最终 SVG。
|
||||
`},
|
||||
{Name: "06_assets.task.md", Content: `# assets
|
||||
|
||||
Inputs:
|
||||
- content/slide_content.json
|
||||
- brief/visual_system.json
|
||||
|
||||
Outputs:
|
||||
- assets/assets_plan.json
|
||||
- assets/charts/*.svg when charts are required
|
||||
- assets/images/* when local images are required
|
||||
|
||||
Receipt:
|
||||
- receipts/assets.json
|
||||
|
||||
Acceptance:
|
||||
- assets_plan.json 必须说明每个资产的 id/type/path/usage。
|
||||
- 图表资产必须写入本地 assets/charts/*.svg。
|
||||
|
||||
Do not:
|
||||
- 不要在 slides/*.svg 中引用远程 URL。
|
||||
`},
|
||||
{Name: "07_svg_author.task.md", Content: `# svg_author
|
||||
|
||||
Inputs:
|
||||
- outline/deck.json
|
||||
- content/slide_content.json
|
||||
- brief/visual_system.json
|
||||
- assets/assets_plan.json
|
||||
- assets/charts/*.svg
|
||||
- assets/images/*
|
||||
|
||||
Outputs:
|
||||
- slides/*.svg
|
||||
|
||||
Receipt:
|
||||
- receipts/svg_author.json
|
||||
|
||||
Acceptance:
|
||||
- 每个 deck slide 的 path 必须对应一个 slides/*.svg。
|
||||
- 必须读取 deck、content、visual_system、assets 后再生成 SVG。
|
||||
- 必须遵守 AnyGen SVG protocol 基本点:纯 SVG、960x540 viewBox、文本可选中、图形语义清晰、无远程资源引用。
|
||||
|
||||
Do not:
|
||||
- 禁止只写背景。
|
||||
- 不要把整页内容栅格化成单张图片。
|
||||
- 不要发布到 Feishu Slides。
|
||||
`},
|
||||
{Name: "08_repair.task.md", Content: `# validate_preview_repair
|
||||
|
||||
Inputs:
|
||||
- slides/*.svg
|
||||
|
||||
Outputs:
|
||||
- receipts/lint.json
|
||||
- receipts/preview.json
|
||||
- repair_queue.md
|
||||
- preview.html
|
||||
|
||||
Receipt:
|
||||
- receipts/validate_preview_repair.json
|
||||
|
||||
Acceptance:
|
||||
- 先运行或触发本地 validate + preview,产出 receipts/lint.json、receipts/preview.json、repair_queue.md、preview.html。
|
||||
- 如果 repair_queue.md 有修复项,再基于 repair_queue.md 与 receipts/lint.json 修复 slides/*.svg。
|
||||
- 修复后再次确认 SVG 保持纯 SVG、可编辑、无远程资源引用。
|
||||
- 最后写 receipts/validate_preview_repair.json,记录 validate、preview、repair 的状态和产物路径。
|
||||
|
||||
Do not:
|
||||
- 不要通过删除内容来绕过检查。
|
||||
- 不要跳过 lint/preview 直接写最终 receipt。
|
||||
`},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultSchemas() map[string]string {
|
||||
return map[string]string{
|
||||
"request.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "input"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"input": {"type": "string"},
|
||||
"audience": {"type": "string"},
|
||||
"delivery_mode": {"type": "string"},
|
||||
"pages": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"source_manifest.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "type"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["local"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"sources.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "excerpt", "usage"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"excerpt": {"type": "string"},
|
||||
"usage": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"design_brief.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["narrative_spine", "depth", "tone"],
|
||||
"properties": {
|
||||
"narrative_spine": {"type": "string"},
|
||||
"depth": {"type": "string"},
|
||||
"tone": {"type": "string"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"visual_system.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["color_system", "typography", "layout_language"],
|
||||
"properties": {
|
||||
"color_system": {"type": "object"},
|
||||
"typography": {"type": "object"},
|
||||
"layout_language": {"type": "string"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"deck.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "slides"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "summary", "role", "key_message", "path"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"key_message": {"type": "string"},
|
||||
"path": {"type": "string", "pattern": "^slides/[^/]+\\.svg$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"slide_content.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides"],
|
||||
"properties": {
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "content"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"notes": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"assets_plan.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["assets"],
|
||||
"properties": {
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "path", "usage"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"usage": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"receipt.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["stage", "status"],
|
||||
"properties": {
|
||||
"stage": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"artifacts": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"lint.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"preview.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "slides"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "rendered"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"rendered": {"type": "boolean"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
153
internal/svglide/receipt.go
Normal file
153
internal/svglide/receipt.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type validationLintReceipt struct {
|
||||
Status string `json:"status"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
func writeValidationArtifacts(safeRoot string, report ValidationReport) error {
|
||||
report = normalizeValidationReport(report)
|
||||
lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(validationLintReceipt{
|
||||
Status: validationReceiptStatus(report),
|
||||
Issues: report.Issues,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644)
|
||||
}
|
||||
|
||||
func normalizeValidationReport(report ValidationReport) ValidationReport {
|
||||
if report.Issues == nil {
|
||||
report.Issues = []ValidationIssue{}
|
||||
}
|
||||
report.OK = len(report.Issues) == 0
|
||||
for i := range report.Issues {
|
||||
report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path)
|
||||
if report.Issues[i].Path == "" {
|
||||
report.Issues[i].Path = "(deck)"
|
||||
}
|
||||
report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code)
|
||||
if report.Issues[i].Code == "" {
|
||||
report.Issues[i].Code = "svglide.validation"
|
||||
}
|
||||
report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity)
|
||||
if report.Issues[i].Severity == "" {
|
||||
report.Issues[i].Severity = "error"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func validationReceiptStatus(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "passed"
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
|
||||
func renderRepairQueue(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "No repair needed.\n"
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Repair Queue\n\n")
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) {
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return "", fmt.Errorf("run file path must not be root")
|
||||
}
|
||||
dirRel := filepath.Dir(cleanRel)
|
||||
if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := safeRunPath(safeRoot, cleanRel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run file path %q must not be a symlink", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("run file path %q must be a regular file", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return path, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err := vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := vfs.Mkdir(cur, 0o755); err != nil {
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
132
internal/svglide/repair.go
Normal file
132
internal/svglide/repair.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RepairReport struct {
|
||||
Status string `json:"status"`
|
||||
LintOK bool `json:"lint_ok"`
|
||||
Preview string `json:"preview"`
|
||||
Reauthored bool `json:"reauthored"`
|
||||
}
|
||||
|
||||
func RepairRun(root string) (RepairReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
lint, validateErr := ValidateRun(root)
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
|
||||
reauthored := false
|
||||
if !lint.OK {
|
||||
repairPaths, ok := authorRepairPaths(lint)
|
||||
if ok {
|
||||
if _, err := authorSlides(root, repairPaths); err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
reauthored = true
|
||||
lint, validateErr = ValidateRun(root)
|
||||
}
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
}
|
||||
|
||||
preview, err := WritePreview(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
report := RepairReport{
|
||||
Status: "failed",
|
||||
LintOK: lint.OK,
|
||||
Preview: preview.Status,
|
||||
Reauthored: reauthored,
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" {
|
||||
report.Status = "passed"
|
||||
}
|
||||
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: StageValidatePreviewRepair,
|
||||
Status: report.Status,
|
||||
Message: repairReceiptMessage(report),
|
||||
Artifacts: []string{
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"repair_queue.md",
|
||||
previewPath,
|
||||
},
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func canRepairByAuthoring(report ValidationReport) bool {
|
||||
_, ok := authorRepairPaths(report)
|
||||
return ok
|
||||
}
|
||||
|
||||
func authorRepairPaths(report ValidationReport) (map[string]bool, bool) {
|
||||
if report.OK || len(report.Issues) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, issue := range report.Issues {
|
||||
path, ok := repairIssueAuthorPath(issue)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
paths[path] = true
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return paths, true
|
||||
}
|
||||
|
||||
func canRepairIssueByAuthoring(issue ValidationIssue) bool {
|
||||
_, ok := repairIssueAuthorPath(issue)
|
||||
return ok
|
||||
}
|
||||
|
||||
func repairIssueAuthorPath(issue ValidationIssue) (string, bool) {
|
||||
path := strings.TrimSpace(issue.Path)
|
||||
slidePath, err := previewSlideObjectPath(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(issue.Code) {
|
||||
case "svglide.path":
|
||||
return slidePath, strings.Contains(issue.Message, "missing or not a regular file")
|
||||
case "svglide.xml", "svglide.root", "svglide.slide_role", "svglide.viewbox", "svglide.visible_content":
|
||||
return slidePath, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func repairReceiptMessage(report RepairReport) string {
|
||||
if report.Status == "passed" {
|
||||
if report.Reauthored {
|
||||
return "lint and preview passed after reauthoring"
|
||||
}
|
||||
return "lint and preview passed"
|
||||
}
|
||||
if report.Reauthored {
|
||||
return "repair reauthored slides but lint or preview still failed"
|
||||
}
|
||||
return "lint or preview failed"
|
||||
}
|
||||
175
internal/svglide/repair_test.go
Normal file
175
internal/svglide/repair_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepairRunAuthorsMissingSlidesAndWritesFinalReceipt(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if !report.LintOK {
|
||||
t.Fatalf("LintOK = false, want true: %+v", report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if !report.Reauthored {
|
||||
t.Fatalf("Reauthored = false, want true: %+v", report)
|
||||
}
|
||||
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"receipts/validate_preview_repair.json",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["stage"] != StageValidatePreviewRepair {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageValidatePreviewRepair)
|
||||
}
|
||||
if receipt["status"] != "passed" {
|
||||
t.Fatalf("receipt status = %v, want passed", receipt["status"])
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["updated_at"]; ok {
|
||||
t.Fatalf("receipt contains updated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunOnlyReauthorsFailedSlidePaths(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
custom := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">KEEP-CUSTOM-01</text></svg>`
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", custom)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != custom {
|
||||
t.Fatalf("slides/01.svg was overwritten:\n%s", string(raw))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "02.svg")); err != nil {
|
||||
t.Fatalf("missing reauthored slides/02.svg: %v", err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunReauthorsBackgroundOnlySVG(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", backgroundOnlySVG())
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunDoesNotAuthorInvalidSlidePath(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/../01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.Reauthored {
|
||||
t.Fatalf("Reauthored = true, want false for invalid path: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("svg_author receipt exists or stat failed, want no authoring: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunTreatsValidationArtifactWriteErrorAsFatal(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
if err := os.Remove(filepath.Join("demo", "repair_queue.md")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join("demo", "repair_queue.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := RepairRun("demo"); err == nil {
|
||||
t.Fatal("expected repair to return validation artifact write error")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("final repair receipt exists or stat failed, want no misleading final receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readRepairReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "validate_preview_repair.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
120
internal/svglide/run.go
Normal file
120
internal/svglide/run.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package svglide
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StageRequest = "request"
|
||||
StageResearch = "research"
|
||||
StageDesignBrief = "design_brief"
|
||||
StageOutline = "outline"
|
||||
StageSlideContent = "slide_content"
|
||||
StageAssets = "assets"
|
||||
StageSVGAuthor = "svg_author"
|
||||
StageValidatePreviewRepair = "validate_preview_repair"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusReady = "ready"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusDone = "done"
|
||||
StatusFailed = "failed"
|
||||
StatusBlocked = "blocked"
|
||||
StatusNeedsRepair = "needs_repair"
|
||||
)
|
||||
|
||||
type Run struct {
|
||||
Version int `json:"version"`
|
||||
Runtime string `json:"runtime"`
|
||||
Command string `json:"command"`
|
||||
Title string `json:"title"`
|
||||
Input string `json:"input"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
Out string `json:"out"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Stages []Stage `json:"stages"`
|
||||
Artifacts ArtifactPaths `json:"artifacts"`
|
||||
Policy Policy `json:"policy"`
|
||||
}
|
||||
|
||||
type Stage struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
Receipt string `json:"receipt"`
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
Deck string `json:"deck"`
|
||||
SlidesDir string `json:"slides_dir"`
|
||||
Preview string `json:"preview"`
|
||||
RepairQueue string `json:"repair_queue"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
PublishEnabled bool `json:"publish_enabled"`
|
||||
NetworkByCodex bool `json:"network_by_codex"`
|
||||
ImageGenerationByCodex bool `json:"image_generation_by_codex"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type NewRunConfig struct {
|
||||
Title string
|
||||
Input string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Out string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func NewRun(cfg NewRunConfig) Run {
|
||||
now := cfg.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
ts := now.Format(time.RFC3339)
|
||||
return Run{
|
||||
Version: 1,
|
||||
Runtime: "codex",
|
||||
Command: "slides +create-svglide",
|
||||
Title: cfg.Title,
|
||||
Input: cfg.Input,
|
||||
Audience: cfg.Audience,
|
||||
DeliveryMode: cfg.DeliveryMode,
|
||||
Pages: cfg.Pages,
|
||||
Out: cfg.Out,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentStage: StageRequest,
|
||||
Stages: DefaultStages(),
|
||||
Artifacts: ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
},
|
||||
Policy: Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByCodex: true,
|
||||
ImageGenerationByCodex: true,
|
||||
Overwrite: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultStages() []Stage {
|
||||
return []Stage{
|
||||
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
|
||||
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"},
|
||||
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Receipt: "receipts/design_brief.json"},
|
||||
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
|
||||
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json"}, Receipt: "receipts/slide_content.json"},
|
||||
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/assets_plan.json"}, Receipt: "receipts/assets.json"},
|
||||
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
|
||||
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"},
|
||||
}
|
||||
}
|
||||
154
internal/svglide/run_test.go
Normal file
154
internal/svglide/run_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultStagesAreOrdered(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
want := []string{
|
||||
StageRequest,
|
||||
StageResearch,
|
||||
StageDesignBrief,
|
||||
StageOutline,
|
||||
StageSlideContent,
|
||||
StageAssets,
|
||||
StageSVGAuthor,
|
||||
StageValidatePreviewRepair,
|
||||
}
|
||||
if len(stages) != len(want) {
|
||||
t.Fatalf("stage count = %d, want %d", len(stages), len(want))
|
||||
}
|
||||
for i, stage := range stages {
|
||||
if stage.Name != want[i] {
|
||||
t.Fatalf("stage[%d] = %q, want %q", i, stage.Name, want[i])
|
||||
}
|
||||
if stage.Status != StatusPending {
|
||||
t.Fatalf("stage[%d].Status = %q, want %q", i, stage.Status, StatusPending)
|
||||
}
|
||||
if stage.Inputs == nil {
|
||||
t.Fatalf("stage[%d].Inputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Outputs == nil {
|
||||
t.Fatalf("stage[%d].Outputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Receipt == "" {
|
||||
t.Fatalf("stage[%d] missing receipt path", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
svgAuthor := mustStage(t, stages, StageSVGAuthor)
|
||||
if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
|
||||
}
|
||||
repair := mustStage(t, stages, StageValidatePreviewRepair)
|
||||
if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
research := mustStage(t, stages, StageResearch)
|
||||
want := []string{"request/request.json", "request/source_manifest.json"}
|
||||
if !reflect.DeepEqual(research.Inputs, want) {
|
||||
t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
outline := mustStage(t, stages, StageOutline)
|
||||
want := []string{"brief/design_brief.json", "brief/visual_system.json"}
|
||||
if !reflect.DeepEqual(outline.Inputs, want) {
|
||||
t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDefaultsToCodexRuntime(t *testing.T) {
|
||||
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品和工程负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Out: ".lark-slides/svglide-runs/demo",
|
||||
Now: now,
|
||||
})
|
||||
if run.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", run.Version)
|
||||
}
|
||||
if run.Runtime != "codex" {
|
||||
t.Fatalf("Runtime = %q, want codex", run.Runtime)
|
||||
}
|
||||
if run.Command != "slides +create-svglide" {
|
||||
t.Fatalf("Command = %q, want slides +create-svglide", run.Command)
|
||||
}
|
||||
if run.Title != "Demo" {
|
||||
t.Fatalf("Title = %q, want Demo", run.Title)
|
||||
}
|
||||
if run.Input != "source.md" {
|
||||
t.Fatalf("Input = %q, want source.md", run.Input)
|
||||
}
|
||||
if run.Audience != "产品和工程负责人" {
|
||||
t.Fatalf("Audience = %q, want 产品和工程负责人", run.Audience)
|
||||
}
|
||||
if run.DeliveryMode != "self_read" {
|
||||
t.Fatalf("DeliveryMode = %q, want self_read", run.DeliveryMode)
|
||||
}
|
||||
if run.Pages != 8 {
|
||||
t.Fatalf("Pages = %d, want 8", run.Pages)
|
||||
}
|
||||
if run.Out != ".lark-slides/svglide-runs/demo" {
|
||||
t.Fatalf("Out = %q, want .lark-slides/svglide-runs/demo", run.Out)
|
||||
}
|
||||
wantTS := now.Format(time.RFC3339)
|
||||
if run.CreatedAt != wantTS {
|
||||
t.Fatalf("CreatedAt = %q, want %q", run.CreatedAt, wantTS)
|
||||
}
|
||||
if run.UpdatedAt != wantTS {
|
||||
t.Fatalf("UpdatedAt = %q, want %q", run.UpdatedAt, wantTS)
|
||||
}
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
wantArtifacts := ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
}
|
||||
if run.Artifacts != wantArtifacts {
|
||||
t.Fatalf("Artifacts = %+v, want %+v", run.Artifacts, wantArtifacts)
|
||||
}
|
||||
wantStages := DefaultStages()
|
||||
if !reflect.DeepEqual(run.Stages, wantStages) {
|
||||
t.Fatalf("Stages = %+v, want %+v", run.Stages, wantStages)
|
||||
}
|
||||
wantPolicy := Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByCodex: true,
|
||||
ImageGenerationByCodex: true,
|
||||
Overwrite: false,
|
||||
}
|
||||
if run.Policy != wantPolicy {
|
||||
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStage(t *testing.T, stages []Stage, name string) Stage {
|
||||
t.Helper()
|
||||
for _, stage := range stages {
|
||||
if stage.Name == name {
|
||||
return stage
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return Stage{}
|
||||
}
|
||||
303
internal/svglide/schema.go
Normal file
303
internal/svglide/schema.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type liteJSONSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
AdditionalProperties *bool `json:"additionalProperties"`
|
||||
Properties map[string]liteJSONSchema `json:"properties"`
|
||||
Items *liteJSONSchema `json:"items"`
|
||||
MinItems *int `json:"minItems"`
|
||||
Enum []string `json:"enum"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
var stageOutputSchemaPaths = map[string]string{
|
||||
"request/request.json": "schemas/request.schema.json",
|
||||
"request/source_manifest.json": "schemas/source_manifest.schema.json",
|
||||
"research/sources.json": "schemas/sources.schema.json",
|
||||
"brief/design_brief.json": "schemas/design_brief.schema.json",
|
||||
"brief/visual_system.json": "schemas/visual_system.schema.json",
|
||||
"outline/deck.json": "schemas/deck.schema.json",
|
||||
"content/slide_content.json": "schemas/slide_content.schema.json",
|
||||
"assets/assets_plan.json": "schemas/assets_plan.schema.json",
|
||||
"receipts/lint.json": "schemas/lint.schema.json",
|
||||
"receipts/preview.json": "schemas/preview.schema.json",
|
||||
}
|
||||
|
||||
func ValidateStageOutputs(root string) error {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, output := range stage.Outputs {
|
||||
if hasGlobMeta(output) || strings.ToLower(filepath.Ext(output)) != ".json" {
|
||||
continue
|
||||
}
|
||||
schemaPath, ok := stageOutputSchemaPaths[output]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateStageOutputSchema(safeRoot, output, schemaPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if output == "outline/deck.json" {
|
||||
if err := validateDeckSlideOutputPaths(safeRoot, output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeckSlideOutputPaths(safeRoot string, artifactPath string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
var deck struct {
|
||||
Slides []struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
if _, err := previewSlideObjectPath(slide.Path); err != nil {
|
||||
return fmt.Errorf("%s: field slides[%d].path: %w", artifactPath, i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStageOutputSchema(safeRoot, artifactPath, schemaPath string) error {
|
||||
artifactRaw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
schemaRaw, err := readRunRegularArtifact(safeRoot, schemaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
schema, err := decodeLiteJSONSchema(schemaRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
value, err := decodeJSONValue(artifactRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
if err := validateJSONValue(schema, value, ""); err != nil {
|
||||
return fmt.Errorf("%s: %w", artifactPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLiteJSONSchema(raw []byte) (liteJSONSchema, error) {
|
||||
var schema liteJSONSchema
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if err := decoder.Decode(&schema); err != nil {
|
||||
return liteJSONSchema{}, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return liteJSONSchema{}, err
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func decodeJSONValue(raw []byte) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func rejectTrailingJSON(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("contains trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONValue(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
switch schema.Type {
|
||||
case "":
|
||||
return nil
|
||||
case "object":
|
||||
return validateJSONObject(schema, value, fieldPath)
|
||||
case "array":
|
||||
return validateJSONArray(schema, value, fieldPath)
|
||||
case "string":
|
||||
return validateJSONString(schema, value, fieldPath)
|
||||
case "integer":
|
||||
if !isJSONInteger(value) {
|
||||
return fmt.Errorf("field %s expected integer, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("field %s expected boolean, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("field %s uses unsupported schema type %q", displayFieldPath(fieldPath), schema.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONObject(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected object, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
for _, required := range schema.Required {
|
||||
if _, ok := object[required]; !ok {
|
||||
return fmt.Errorf("field %s is required", joinFieldPath(fieldPath, required))
|
||||
}
|
||||
}
|
||||
if schema.AdditionalProperties != nil && !*schema.AdditionalProperties {
|
||||
for name := range object {
|
||||
if _, ok := schema.Properties[name]; !ok {
|
||||
return fmt.Errorf("field %s is not allowed by additionalProperties:false", joinFieldPath(fieldPath, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, propertySchema := range schema.Properties {
|
||||
child, ok := object[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateJSONValue(propertySchema, child, joinFieldPath(fieldPath, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONArray(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
array, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected array, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if schema.MinItems != nil && len(array) < *schema.MinItems {
|
||||
return fmt.Errorf("field %s has %d items, want minItems %d", displayFieldPath(fieldPath), len(array), *schema.MinItems)
|
||||
}
|
||||
if schema.Items == nil {
|
||||
return nil
|
||||
}
|
||||
for i, item := range array {
|
||||
if err := validateJSONValue(*schema.Items, item, joinArrayFieldPath(fieldPath, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONString(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected string, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if len(schema.Enum) > 0 {
|
||||
for _, allowed := range schema.Enum {
|
||||
if text == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("field %s value %q is not in enum %v", displayFieldPath(fieldPath), text, schema.Enum)
|
||||
}
|
||||
if schema.Pattern != "" {
|
||||
matched, err := regexp.MatchString(schema.Pattern, text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %s has invalid pattern %q: %w", displayFieldPath(fieldPath), schema.Pattern, err)
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("field %s value %q does not match pattern %q", displayFieldPath(fieldPath), text, schema.Pattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isJSONInteger(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return isCanonicalJSONInteger(typed.String())
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isCanonicalJSONInteger(value string) bool {
|
||||
if value == "" || strings.ContainsAny(value, ".eE") {
|
||||
return false
|
||||
}
|
||||
var parsed big.Int
|
||||
_, ok := parsed.SetString(value, 10)
|
||||
return ok
|
||||
}
|
||||
|
||||
func jsonValueType(value any) string {
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
case []any:
|
||||
return "array"
|
||||
case string:
|
||||
return "string"
|
||||
case json.Number, float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return fmt.Sprintf("%T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func joinFieldPath(parent, name string) string {
|
||||
if parent == "" {
|
||||
return name
|
||||
}
|
||||
return parent + "." + name
|
||||
}
|
||||
|
||||
func joinArrayFieldPath(parent string, index int) string {
|
||||
if parent == "" {
|
||||
return fmt.Sprintf("[%d]", index)
|
||||
}
|
||||
return fmt.Sprintf("%s[%d]", parent, index)
|
||||
}
|
||||
|
||||
func displayFieldPath(path string) string {
|
||||
if path == "" {
|
||||
return "$"
|
||||
}
|
||||
return path
|
||||
}
|
||||
159
internal/svglide/schema_test.go
Normal file
159
internal/svglide/schema_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateStageOutputsRejectsMissingRequiredField(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(`{"title":"Demo"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "input") {
|
||||
t.Fatalf("error = %v, want path and missing field", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsCurrentRequestArtifacts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsDeckSlidePathsThatPreviewRejects(t *testing.T) {
|
||||
for _, path := range []string{"slides/a%20.svg", "slides/.hidden.svg", "slides/a..b.svg", "slides/a:b.svg"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"`+path+`"}]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outline/deck.json") || !strings.Contains(err.Error(), "slides[0].path") {
|
||||
t.Fatalf("error = %v, want deck path context", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidDeckSlidePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/a%20.svg"}]}`)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageOutline {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageOutline)
|
||||
}
|
||||
if got := stageStatus(t, run, StageOutline); got == StatusDone {
|
||||
t.Fatalf("outline stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "outline.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("outline receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidValidatePreviewRepairReceipts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "lint",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"failed"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/lint.json",
|
||||
},
|
||||
{
|
||||
name: "preview",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","slides":[{"path":"slides/01.svg","rendered":"yes"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/preview.json",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
tt.setup(t)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.path) {
|
||||
t.Fatalf("error = %v, want path %s", err, tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsNonCanonicalIntegers(t *testing.T) {
|
||||
for _, pages := range []string{"8.0", "8e0", "0.99999999999999999"} {
|
||||
t.Run(pages, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
raw := `{"title":"Demo","input":"source.md","pages":` + pages + `}`
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "pages") {
|
||||
t.Fatalf("error = %v, want path and pages field", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidCurrentStageOutputSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "source_manifest.json"), []byte(`{"sources":[{"path":"source.md","type":"remote"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "request.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
120
internal/svglide/stage.go
Normal file
120
internal/svglide/stage.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StageReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
func CompleteCurrentStage(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
index, stage, err := currentStageWithIndex(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if len(missingOutputs) > 0 {
|
||||
return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", "))
|
||||
}
|
||||
|
||||
if err := ValidateStageOutputs(root); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
if err := validateFinalStageReceiptsPassed(safeRoot); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: stage.Name,
|
||||
Status: StatusDone,
|
||||
Artifacts: stage.Outputs,
|
||||
}); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
|
||||
run.Stages[index].Status = StatusDone
|
||||
if index < len(run.Stages)-1 {
|
||||
nextStage := &run.Stages[index+1]
|
||||
run.CurrentStage = nextStage.Name
|
||||
if nextStage.Status == "" {
|
||||
nextStage.Status = StatusPending
|
||||
}
|
||||
} else {
|
||||
run.CurrentStage = stage.Name
|
||||
}
|
||||
run.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
if err := writeRunFile(safeRoot, run); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return InspectStatus(root)
|
||||
}
|
||||
|
||||
type stageStatusReceipt struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func validateFinalStageReceiptsPassed(safeRoot string) error {
|
||||
for _, path := range []string{"receipts/lint.json", "receipts/preview.json"} {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read receipt: %w", path, err)
|
||||
}
|
||||
var receipt stageStatusReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", path, err)
|
||||
}
|
||||
if receipt.Status != "passed" {
|
||||
return fmt.Errorf("%s: status is %q, want passed", path, receipt.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentStageWithIndex(run Run) (int, Stage, error) {
|
||||
for i, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return i, stage, nil
|
||||
}
|
||||
}
|
||||
return -1, Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func writeRunFile(safeRoot string, run Run) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, "run.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, run)
|
||||
}
|
||||
|
||||
func writeStageReceipt(safeRoot string, receipt StageReceipt) error {
|
||||
if strings.TrimSpace(receipt.Stage) == "" {
|
||||
return fmt.Errorf("stage receipt stage must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(receipt.Stage, `/\`) || receipt.Stage == "." || receipt.Stage == ".." {
|
||||
return fmt.Errorf("stage receipt stage %q must be a file name", receipt.Stage)
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, filepath.Join("receipts", receipt.Stage+".json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, receipt)
|
||||
}
|
||||
116
internal/svglide/stage_test.go
Normal file
116
internal/svglide/stage_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompleteCurrentStageAdvancesToNextStage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageResearch {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageResearch {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageResearch)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got != StatusDone {
|
||||
t.Fatalf("request stage status = %q, want %q", got, StatusDone)
|
||||
}
|
||||
if got := stageStatus(t, run, StageResearch); got != StatusPending {
|
||||
t.Fatalf("research stage status = %q, want %q", got, StatusPending)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt StageReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt.Stage != StageRequest || receipt.Status != StatusDone {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsMissingOutput(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing output error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageDoesNotAdvanceRunWhenReceiptWriteFails(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Mkdir(filepath.Join("demo", "receipts", "request.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected receipt write error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedValidatePreviewRepairReceipts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"failed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"failed","slides":[{"path":"slides/01.svg","rendered":false}]}`)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed lint/preview receipts to block completion")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func stageStatus(t *testing.T, run Run, name string) string {
|
||||
t.Helper()
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == name {
|
||||
return stage.Status
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return ""
|
||||
}
|
||||
391
internal/svglide/status.go
Normal file
391
internal/svglide/status.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type StatusReport struct {
|
||||
CurrentStage string `json:"current_stage"`
|
||||
MissingInputs []string `json:"missing_inputs"`
|
||||
MissingOutputs []string `json:"missing_outputs"`
|
||||
NextCommand string `json:"next_command"`
|
||||
}
|
||||
|
||||
type NextTaskReport struct {
|
||||
Stage string `json:"stage"`
|
||||
PromptPath string `json:"prompt_path"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
}
|
||||
|
||||
func ReadRun(root string) (Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return readRunFile(safeRoot)
|
||||
}
|
||||
|
||||
func InspectStatus(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return StatusReport{
|
||||
CurrentStage: stage.Name,
|
||||
MissingInputs: missingInputs,
|
||||
MissingOutputs: missingOutputs,
|
||||
NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action next --run %s", shellQuote(root)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NextTask(root string) (NextTaskReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
if len(missingInputs) > 0 {
|
||||
return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", "))
|
||||
}
|
||||
promptPath, err := promptPathForStage(stage.Name)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
inputs, err := validateRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
outputs, err := validateRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
return NextTaskReport{
|
||||
Stage: stage.Name,
|
||||
PromptPath: promptPath,
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readRun(root string) (string, Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
return safeRoot, run, nil
|
||||
}
|
||||
|
||||
func readRunFile(safeRoot string) (Run, error) {
|
||||
raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json"))
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
return Run{}, fmt.Errorf("read run.json: %w", err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func currentStage(run Run) (Stage, error) {
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return stage, nil
|
||||
}
|
||||
}
|
||||
return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func missingRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
var missing []string
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
exists, err := runGlobExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
continue
|
||||
}
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lstat run path %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
func validateRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
paths := make([]string, 0, len(rels))
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err := safeRunPath(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func runGlobExists(safeRoot, rel string) (bool, error) {
|
||||
dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
dirPath, exists, err := runDirectoryExists(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
entries, err := vfs.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read glob directory for %q: %w", rel, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := filepath.Match(pattern, entry.Name())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
matchRel := filepath.Join(dirRel, entry.Name())
|
||||
exists, err := runRegularFileExists(safeRoot, matchRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err)
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", "", "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
dirRel, pattern := filepath.Split(cleanRel)
|
||||
dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator))
|
||||
if pattern == "" {
|
||||
return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel)
|
||||
}
|
||||
if _, err := filepath.Match(pattern, ""); err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if dirRel == "" {
|
||||
dirRel = "."
|
||||
}
|
||||
if hasGlobMeta(dirRel) {
|
||||
return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel)
|
||||
}
|
||||
dirPath, err := safeRunPath(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return dirRel, pattern, dirPath, nil
|
||||
}
|
||||
|
||||
func runDirectoryExists(safeRoot, rel string) (string, bool, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return path, false, err
|
||||
}
|
||||
if !exists {
|
||||
return path, false, nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return path, false, fmt.Errorf("run path %q is not a directory", rel)
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func runRegularFileExists(safeRoot, rel string) (bool, error) {
|
||||
info, _, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
return info.Mode().IsRegular(), nil
|
||||
}
|
||||
|
||||
func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
var info fs.FileInfo
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
if info == nil {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
|
||||
func hasGlobMeta(path string) bool {
|
||||
return strings.ContainsAny(path, "*?[")
|
||||
}
|
||||
|
||||
func safeRunPath(safeRoot, rel string) (string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
path := filepath.Clean(filepath.Join(safeRoot, cleanRel))
|
||||
rootRel, err := filepath.Rel(safeRoot, path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err)
|
||||
}
|
||||
if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) {
|
||||
return "", fmt.Errorf("run path %q escapes run root", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isAbsoluteRunPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||
drive := path[0]
|
||||
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
if value == "" {
|
||||
return "''"
|
||||
}
|
||||
if isShellBareword(value) {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func isShellBareword(value string) bool {
|
||||
for _, r := range value {
|
||||
if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune("_@%+=:,./-", r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func promptPathForStage(stage string) (string, error) {
|
||||
switch stage {
|
||||
case StageRequest:
|
||||
return "prompts/01_request.task.md", nil
|
||||
case StageResearch:
|
||||
return "prompts/02_research.task.md", nil
|
||||
case StageDesignBrief:
|
||||
return "prompts/03_design_brief.task.md", nil
|
||||
case StageOutline:
|
||||
return "prompts/04_outline.task.md", nil
|
||||
case StageSlideContent:
|
||||
return "prompts/05_slide_content.task.md", nil
|
||||
case StageAssets:
|
||||
return "prompts/06_assets.task.md", nil
|
||||
case StageSVGAuthor:
|
||||
return "prompts/07_svg_author.task.md", nil
|
||||
case StageValidatePreviewRepair:
|
||||
return "prompts/08_repair.task.md", nil
|
||||
default:
|
||||
return "", fmt.Errorf("stage %q has no prompt mapping", stage)
|
||||
}
|
||||
}
|
||||
449
internal/svglide/status_test.go
Normal file
449
internal/svglide/status_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatusReportsMissingOutputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs)
|
||||
}
|
||||
if len(status.MissingInputs) != 0 {
|
||||
t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs)
|
||||
}
|
||||
if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" {
|
||||
t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusQuotesNextCommandRunPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
root string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
root: "demo dir",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo dir'",
|
||||
},
|
||||
{
|
||||
root: "demo' dir",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo'\\'' dir'",
|
||||
},
|
||||
{
|
||||
root: "demo trail ",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo trail '",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.root, func(t *testing.T) {
|
||||
cwd := initStatusTestRunAt(t, tt.root)
|
||||
|
||||
status, err := InspectStatus(tt.root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.NextCommand != tt.want {
|
||||
t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want)
|
||||
}
|
||||
if strings.Contains(status.NextCommand, cwd) {
|
||||
t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextReturnsCurrentTaskPrompt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if next.Stage != StageRequest {
|
||||
t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest)
|
||||
}
|
||||
if next.PromptPath != "prompts/01_request.task.md" {
|
||||
t.Fatalf("PromptPath = %q, want prompts/01_request.task.md", next.PromptPath)
|
||||
}
|
||||
if filepath.IsAbs(next.PromptPath) {
|
||||
t.Fatalf("PromptPath = %q, want relative path", next.PromptPath)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", next.PromptPath)); err != nil {
|
||||
t.Fatalf("missing prompt %s: %v", next.PromptPath, err)
|
||||
}
|
||||
if len(next.Inputs) != 0 {
|
||||
t.Fatalf("Inputs = %v, want empty", next.Inputs)
|
||||
}
|
||||
if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) {
|
||||
t.Fatalf("Outputs = %v, want request outputs", next.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if _, err := InspectStatus("../escape"); err == nil {
|
||||
t.Fatal("expected unsafe run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
|
||||
run, err := ReadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
|
||||
if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil {
|
||||
t.Fatal("expected absolute run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected stat error when output parent is a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
path := filepath.Join("demo", "request", "source_manifest.json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageDesignBrief
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing current stage inputs to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing research source manifest to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageOutline
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing outline visual system to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/request.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides")
|
||||
if err := os.MkdirAll(outsideSlides, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside.svg")
|
||||
if err := os.WriteFile(outside, []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func initStatusTestRun(t *testing.T) string {
|
||||
return initStatusTestRunAt(t, "demo")
|
||||
}
|
||||
|
||||
func initStatusTestRunAt(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initRoot := root
|
||||
if trimmed := strings.TrimSpace(root); trimmed != root {
|
||||
initRoot = trimmed
|
||||
}
|
||||
if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if initRoot != root {
|
||||
if err := os.Rename(initRoot, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func readStatusTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeStatusTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) {
|
||||
t.Helper()
|
||||
for i := range run.Stages {
|
||||
if run.Stages[i].Name == stageName {
|
||||
run.Stages[i].Outputs = outputs
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", stageName)
|
||||
}
|
||||
|
||||
func setCurrentStageForStatusTest(t *testing.T, stageName string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = stageName
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
440
internal/svglide/validate.go
Normal file
440
internal/svglide/validate.go
Normal file
@@ -0,0 +1,440 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const slideNamespace = "https://slides.bytedance.com/ns"
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
const xlinkNamespace = "http://www.w3.org/1999/xlink"
|
||||
|
||||
type ValidationReport struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ValidationIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
type validationDeck struct {
|
||||
Slides []validationDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type validationDeckSlide struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type svgViewBox struct {
|
||||
Width float64
|
||||
Height float64
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type svgLintElement struct {
|
||||
Excluded bool
|
||||
TextCandidate bool
|
||||
}
|
||||
|
||||
func ValidateRun(root string) (ValidationReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ValidationReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return failValidation(safeRoot, ValidationIssue{Code: "svglide.deck", Message: "deck artifact path is empty"}, fmt.Errorf("deck artifact path is empty"))
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
var deck validationDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains no slides", deckPath)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := ValidationReport{Issues: []ValidationIssue{}}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath := strings.TrimSpace(slide.Path)
|
||||
if slidePath == "" {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Code: "svglide.path", Message: "slide path must not be empty"})
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Path: slidePath, Code: "svglide.path", Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
report.Issues = append(report.Issues, lintSVG(slidePath, raw)...)
|
||||
}
|
||||
report = normalizeValidationReport(report)
|
||||
|
||||
if err := writeValidationArtifacts(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func failValidation(safeRoot string, issue ValidationIssue, err error) (ValidationReport, error) {
|
||||
report := ValidationReport{Issues: []ValidationIssue{issue}}
|
||||
report = normalizeValidationReport(report)
|
||||
if writeErr := writeValidationArtifacts(safeRoot, report); writeErr != nil {
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%w; write validation artifacts: %v", err, writeErr)
|
||||
}
|
||||
return report, writeErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readRunRegularArtifact(safeRoot string, rel string) ([]byte, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("run path %q is missing or not a regular file inside run root", rel)
|
||||
}
|
||||
raw, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read run path %q: %w", rel, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func lintSVG(path string, raw []byte) []ValidationIssue {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var issues []ValidationIssue
|
||||
var rootSeen bool
|
||||
var rootIsSVG bool
|
||||
var hasSlideRole bool
|
||||
var hasViewBox bool
|
||||
var hasVisibleContent bool
|
||||
var viewBox svgViewBox
|
||||
var stack []svgLintElement
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: fmt.Sprintf("invalid XML: %v", err)}}
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded
|
||||
excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed)
|
||||
ctx := svgLintElement{
|
||||
Excluded: excluded,
|
||||
TextCandidate: elementIsTextCandidate(typed),
|
||||
}
|
||||
if !rootSeen {
|
||||
rootSeen = true
|
||||
rootIsSVG = typed.Name.Local == "svg" && typed.Name.Space == svgNamespace
|
||||
hasSlideRole = hasRootSlideRole(typed)
|
||||
viewBox, hasViewBox = rootViewBox(typed)
|
||||
stack = append(stack, ctx)
|
||||
continue
|
||||
}
|
||||
if elementCountsAsVisibleContent(typed, viewBox, excluded) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
stack = append(stack, ctx)
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(typed)) != "" && activeVisibleTextCandidate(stack) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !rootSeen {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: "invalid XML: missing root element"}}
|
||||
}
|
||||
if !rootIsSVG {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.root", Message: "root element must be <svg>"})
|
||||
}
|
||||
if !hasSlideRole {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.slide_role", Message: `root element must include slide:role="slide"`})
|
||||
}
|
||||
if !hasViewBox {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include viewBox"})
|
||||
} else if !viewBox.Valid {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include valid viewBox"})
|
||||
}
|
||||
if rootIsSVG && !hasVisibleContent {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.visible_content", Message: "slide contains only background/placeholder content"})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func hasRootSlideRole(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if strings.TrimSpace(attr.Value) != "slide" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Local == "role" && attr.Name.Space == slideNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rootViewBox(start xml.StartElement) (svgViewBox, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" || attr.Name.Local != "viewBox" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
return parseViewBox(attr.Value), true
|
||||
}
|
||||
return svgViewBox{}, false
|
||||
}
|
||||
|
||||
func parseViewBox(value string) svgViewBox {
|
||||
fields := strings.Fields(strings.ReplaceAll(value, ",", " "))
|
||||
if len(fields) != 4 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values := make([]float64, 4)
|
||||
for i, field := range fields {
|
||||
parsed, err := strconv.ParseFloat(field, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values[i] = parsed
|
||||
}
|
||||
width := values[2]
|
||||
height := values[3]
|
||||
if width <= 0 || height <= 0 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
return svgViewBox{Width: width, Height: height, Valid: true}
|
||||
}
|
||||
|
||||
func elementCountsAsVisibleContent(start xml.StartElement, viewBox svgViewBox, excluded bool) bool {
|
||||
if excluded {
|
||||
return false
|
||||
}
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "text", "tspan":
|
||||
return false
|
||||
case "foreignObject", "chart":
|
||||
return true
|
||||
case "image", "use":
|
||||
return elementHasHref(start)
|
||||
case "g":
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
case "path", "circle", "ellipse", "line", "polyline", "polygon":
|
||||
return true
|
||||
case "rect":
|
||||
return !isBackgroundRect(start, viewBox)
|
||||
default:
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
}
|
||||
}
|
||||
|
||||
func activeVisibleTextCandidate(stack []svgLintElement) bool {
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
if stack[i].Excluded {
|
||||
return false
|
||||
}
|
||||
if stack[i].TextCandidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func elementIsTextCandidate(start xml.StartElement) bool {
|
||||
return start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan")
|
||||
}
|
||||
|
||||
func elementIsHidden(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
switch attr.Name.Local {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(attr.Value) {
|
||||
return true
|
||||
}
|
||||
case "style":
|
||||
if styleHidesElement(attr.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func styleHidesElement(style string) bool {
|
||||
for _, declaration := range strings.Split(style, ";") {
|
||||
name, value, ok := strings.Cut(declaration, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func opacityIsZero(value string) bool {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return false
|
||||
}
|
||||
return floatEqual(parsed, 0)
|
||||
}
|
||||
|
||||
func elementIsNonRendering(start xml.StartElement) bool {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "defs", "symbol", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "marker", "metadata", "title", "desc", "style", "script":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elementHasHref(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local != "href" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Space == "" || attr.Name.Space == xlinkNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSemanticMarker(start xml.StartElement, terms ...string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(attr.Name.Local)
|
||||
if name != "role" && name != "class" && name != "id" && !strings.HasPrefix(name, "data-") {
|
||||
continue
|
||||
}
|
||||
value := strings.ToLower(attr.Value)
|
||||
for _, term := range terms {
|
||||
if strings.Contains(value, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBackgroundRect(start xml.StartElement, viewBox svgViewBox) bool {
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return true
|
||||
}
|
||||
width := attrValue(start, "width")
|
||||
height := attrValue(start, "height")
|
||||
if width == "100%" && height == "100%" {
|
||||
return true
|
||||
}
|
||||
if !viewBox.Valid {
|
||||
return false
|
||||
}
|
||||
x := attrFloatDefault(start, "x", 0)
|
||||
y := attrFloatDefault(start, "y", 0)
|
||||
w, okW := parseAttrFloat(width)
|
||||
h, okH := parseAttrFloat(height)
|
||||
if !okW || !okH {
|
||||
return false
|
||||
}
|
||||
return floatEqual(x, 0) && floatEqual(y, 0) && floatEqual(w, viewBox.Width) && floatEqual(h, viewBox.Height)
|
||||
}
|
||||
|
||||
func attrValue(start xml.StartElement, name string) string {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == name {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func attrFloatDefault(start xml.StartElement, name string, fallback float64) float64 {
|
||||
value := attrValue(start, name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, ok := parseAttrFloat(value)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseAttrFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func floatEqual(a float64, b float64) bool {
|
||||
return math.Abs(a-b) < 0.001
|
||||
}
|
||||
711
internal/svglide/validate_test.go
Normal file
711
internal/svglide/validate_test.go
Normal file
@@ -0,0 +1,711 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRunRejectsBackgroundOnlySVGAndWritesRepairArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), backgroundOnlySVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected background-only SVG issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") {
|
||||
t.Fatalf("Issues = %+v, want background/placeholder issue", report.Issues)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || len(receipt.Issues) == 0 {
|
||||
t.Fatalf("lint receipt = %+v, want failing issues", receipt)
|
||||
}
|
||||
var lintReceipt validationLintReceipt
|
||||
if err := json.Unmarshal(raw, &lintReceipt); err != nil {
|
||||
t.Fatalf("lint receipt is not schema-compatible JSON: %v", err)
|
||||
}
|
||||
if lintReceipt.Status != "failed" {
|
||||
t.Fatalf("lint receipt status = %q, want failed", lintReceipt.Status)
|
||||
}
|
||||
if lintReceipt.Issues[0].Code == "" || lintReceipt.Issues[0].Severity == "" {
|
||||
t.Fatalf("lint receipt issue = %+v, want code and severity", lintReceipt.Issues[0])
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), "slides/01.svg") {
|
||||
t.Fatalf("repair queue = %q, want slide path", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunPassesVisibleTextSVG(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("Issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(queue)) != "No repair needed." {
|
||||
t.Fatalf("repair queue = %q, want no repair text", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEscapingSlidePath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "../outside.svg")
|
||||
writeValidateTestFile(t, "outside.svg", visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with escaping slide path: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsSlideSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: "slides/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(filepath.Join(outside, "01.svg"), filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: "slides/link/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.deckPath)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "01.svg"), []byte(visibleTextSVG()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked slide path: %+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDeckSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: filepath.Join("demo", "outline", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(outside, "deck.json"), filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: filepath.Join("demo", "outline_link", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "outline_link/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "outline_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeMinimalDeckAt(t, filepath.Join(outside, "deck.json"), "slides/01.svg")
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked deck path %q: %+v", tt.deckPath, report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEmptyDeck(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo")
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, "no slides")
|
||||
}
|
||||
|
||||
func TestValidateRunWritesRepairArtifactsForDeckReadFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing deck",
|
||||
setup: func(t *testing.T) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
{
|
||||
name: "invalid deck json",
|
||||
setup: func(t *testing.T) {
|
||||
writeValidateTestFile(t, filepath.Join("demo", "outline", "deck.json"), `{`)
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
tt.setup(t)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReadsDeckFromRunArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/bad.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReportsInvalidXML(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg><text>broken`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "XML") && !validationIssuesContain(report.Issues, "xml") {
|
||||
t.Fatalf("Issues = %+v, want XML parse issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRequiresSVGRootSlideRoleAndViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non svg root",
|
||||
svg: `<html><body>not svg</body></html>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "wrong svg namespace",
|
||||
svg: `<svg xmlns="https://wrong.example/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "missing slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "missing viewBox",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text>hello</text></svg>`,
|
||||
want: `viewBox`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespaced slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:foo="https://wrong.example" foo:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "unbound slide prefix role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, tt.want) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsInvalidViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
}{
|
||||
{
|
||||
name: "bad viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox origin fields",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad bad 960 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nan viewBox width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 NaN 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "zero viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 0 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox with full page rect",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><rect width="960" height="540" fill="#fff"/></svg>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresNonVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "text in defs",
|
||||
body: `<defs><text>hidden template</text></defs>`,
|
||||
},
|
||||
{
|
||||
name: "display none text",
|
||||
body: `<text display="none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden text",
|
||||
body: `<text visibility="hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style display none text",
|
||||
body: `<text style="display:none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style visibility hidden text",
|
||||
body: `<text style="visibility:hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "opacity zero text",
|
||||
body: `<text opacity="0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style opacity zero text",
|
||||
body: `<text style="opacity:0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
body: `<text> </text>`,
|
||||
},
|
||||
{
|
||||
name: "image without href",
|
||||
body: `<image width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "use without href",
|
||||
body: `<use x="10" y="10"/>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsWrongNamespaceVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "wrong namespace path",
|
||||
body: `<bad:path xmlns:bad="https://wrong.example/svg" d="M10 10h20v20z"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace text",
|
||||
body: `<bad:text xmlns:bad="https://wrong.example/svg">hidden by namespace</bad:text>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace image href",
|
||||
body: `<image xmlns:bad="https://wrong.example/svg" bad:href="asset.png" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace viewBox",
|
||||
body: `<text>hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
viewBox := `viewBox="0 0 960 540"`
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
viewBox = `bad:viewBox="0 0 960 540" xmlns:bad="https://wrong.example/svg"`
|
||||
}
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" ` + viewBox + `>` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsNamespacedXLinkHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" slide:role="slide" viewBox="0 0 960 540"><image xlink:href="asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsPlainHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image href="asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected receipt symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "lint.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lint receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsLintReceiptFileSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "lint.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-lint.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected lint receipt file symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside file was overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initValidateTestRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitRun("demo", InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func writeMinimalDeck(t *testing.T, root string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
writeMinimalDeckAt(t, filepath.Join(root, "outline", "deck.json"), slidePaths...)
|
||||
}
|
||||
|
||||
func writeMinimalDeckAt(t *testing.T, path string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
slides := make([]map[string]string, 0, len(slidePaths))
|
||||
for i, path := range slidePaths {
|
||||
slides = append(slides, map[string]string{
|
||||
"id": "slide-" + string(rune('1'+i)),
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "content",
|
||||
"key_message": "Message",
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": slides,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
|
||||
func readValidateTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeValidateTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidateTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundOnlySVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/></svg>`
|
||||
}
|
||||
|
||||
func visibleTextSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func validationIssuesContain(issues []ValidationIssue, needle string) bool {
|
||||
for _, issue := range issues {
|
||||
if strings.Contains(issue.Path, needle) || strings.Contains(issue.Message, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertValidationFailureArtifacts(t *testing.T, root string, report ValidationReport, needle string) {
|
||||
t.Helper()
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected validation issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, needle) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, needle)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(root, "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || !validationIssuesContain(receipt.Issues, needle) {
|
||||
t.Fatalf("lint receipt = %+v, want failing issue containing %q", receipt, needle)
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join(root, "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), needle) {
|
||||
t.Fatalf("repair queue = %q, want %q", string(queue), needle)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
|
||||
return DefaultFS.OpenFile(name, flag, perm)
|
||||
}
|
||||
func CreateTemp(dir, pattern string) (*os.File, error) { return DefaultFS.CreateTemp(dir, pattern) }
|
||||
func Mkdir(path string, perm fs.FileMode) error { return DefaultFS.Mkdir(path, perm) }
|
||||
func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirAll(path, perm) }
|
||||
func MkdirTemp(dir, pattern string) (string, error) { return DefaultFS.MkdirTemp(dir, pattern) }
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
|
||||
@@ -25,6 +25,7 @@ type FS interface {
|
||||
CreateTemp(dir, pattern string) (*os.File, error)
|
||||
|
||||
// Directory/File management
|
||||
Mkdir(path string, perm fs.FileMode) error
|
||||
MkdirAll(path string, perm fs.FileMode) error
|
||||
MkdirTemp(dir, pattern string) (string, error)
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
|
||||
@@ -30,6 +30,7 @@ func (OsFs) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)
|
||||
func (OsFs) CreateTemp(dir, pattern string) (*os.File, error) { return os.CreateTemp(dir, pattern) }
|
||||
|
||||
// Directory/File management
|
||||
func (OsFs) Mkdir(path string, perm fs.FileMode) error { return os.Mkdir(path, perm) }
|
||||
func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
func (OsFs) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) }
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
|
||||
@@ -23,6 +23,15 @@ func TestOsFsBasicOperations(t *testing.T) {
|
||||
fs := OsFs{}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Mkdir
|
||||
one := filepath.Join(dir, "one")
|
||||
if err := fs.Mkdir(one, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
if err := Mkdir(filepath.Join(dir, "two"), 0o755); err != nil {
|
||||
t.Fatalf("package Mkdir: %v", err)
|
||||
}
|
||||
|
||||
// MkdirAll
|
||||
sub := filepath.Join(dir, "a", "b")
|
||||
if err := fs.MkdirAll(sub, 0o755); err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
|
||||
@@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("title") && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
@@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
|
||||
)
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("content") != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCreateBody(runtime)
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
desc := "OpenAPI: create document"
|
||||
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."
|
||||
@@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
}
|
||||
|
||||
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildCreateBody(runtime)
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
|
||||
if err != nil {
|
||||
@@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
|
||||
func buildCreateContent(runtime *common.RuntimeContext) string {
|
||||
content := runtime.Str("content")
|
||||
return buildCreateContentWithBody(runtime, runtime.Str("content"))
|
||||
}
|
||||
|
||||
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if title == "" {
|
||||
return content
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
@@ -71,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
|
||||
return err
|
||||
}
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
|
||||
@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
|
||||
if got["enable_user_cite_reference_map"] != true {
|
||||
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
|
||||
}
|
||||
if _, ok := got["return_html5_block_data"]; ok {
|
||||
t.Fatalf("extra_param should not request html5 block data: %#v", got)
|
||||
if got["return_html5_block_data"] != true {
|
||||
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
|
||||
}
|
||||
if _, ok := got["reference_map_mode"]; ok {
|
||||
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +579,46 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdownFence",
|
||||
"revision_id": float64(1),
|
||||
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdownFence",
|
||||
"--doc-format", "im-markdown",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if errField, ok := envelope["error"]; ok {
|
||||
t.Fatalf("fetch output should not contain error: %#v", errField)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
|
||||
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
|
||||
}
|
||||
if _, ok := doc["reference_map"]; ok {
|
||||
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -63,6 +64,39 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"content": `<html5-block path="@missing.html"></html5-block>`,
|
||||
})
|
||||
|
||||
_, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "path" {
|
||||
t.Fatalf("param = %q, want path", validationErr.Param)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
|
||||
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
@@ -115,13 +117,20 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
|
||||
}
|
||||
}
|
||||
if content != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(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).
|
||||
@@ -134,7 +143,7 @@ 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 := buildUpdateBodyWithReferenceMap(runtime)
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
696
shortcuts/doc/html5_block_resources.go
Normal file
696
shortcuts/doc/html5_block_resources.go
Normal file
@@ -0,0 +1,696 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
html5BlockTag = "html5-block"
|
||||
html5BlockPathAttr = "path"
|
||||
html5BlockDataRefAttr = "data-ref"
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
Data string `json:"data,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
|
||||
|
||||
type docsV2WriteInput struct {
|
||||
Content string
|
||||
ReferenceMap map[string]interface{}
|
||||
}
|
||||
|
||||
type html5BlockAttr struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
type html5BlockStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
return body, nil
|
||||
}
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["content"] = buildCreateContentWithBody(runtime, input.Content)
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildUpdateBody(runtime)
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Content != "" {
|
||||
body["content"] = input.Content
|
||||
}
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("reference-map") && runtime.Str("content") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) {
|
||||
input := docsV2WriteInput{Content: runtime.Str("content")}
|
||||
if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" {
|
||||
refMap, err := parseReferenceMapObject(raw, "--reference-map")
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
input.ReferenceMap = refMap
|
||||
}
|
||||
return prepareDocsV2WriteInput(runtime, input)
|
||||
}
|
||||
|
||||
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
|
||||
refMap := cloneReferenceMapObject(input.ReferenceMap)
|
||||
html5RefMap, err := html5ReferenceMapFromObject(refMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
|
||||
return docsV2WriteInput{
|
||||
Content: content,
|
||||
ReferenceMap: refMap,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) {
|
||||
if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var refMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) {
|
||||
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var refMap html5BlockReferenceMap
|
||||
if err := json.Unmarshal(raw, &refMap); err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
|
||||
}
|
||||
return compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) {
|
||||
if !strings.Contains(content, "<html5-block") {
|
||||
return content, compactReferenceMap(refMap), nil
|
||||
}
|
||||
if err := validateHTML5BlockWriteElementBodies(format, content); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
refMap = cloneReferenceMap(refMap)
|
||||
if refMap == nil {
|
||||
refMap = html5BlockReferenceMap{}
|
||||
}
|
||||
ensureReferenceGroup(refMap, html5BlockTag)
|
||||
nextRef := nextHTML5BlockRef(refMap)
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
|
||||
tag, err := parseHTML5BlockStartTag(raw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid html5-block tag: %v", err).WithParam("html5-block")
|
||||
}
|
||||
if tag.hasAttr(html5BlockDataAttr) {
|
||||
return "", common.ValidationErrorf("html5-block data is reserved for SDK internals; use data-ref with reference_map or path=\"@relative.html\"").WithParam("html5-block")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(html5BlockPathAttr)
|
||||
dataRef, hasDataRef := tag.attr(html5BlockDataRefAttr)
|
||||
if hasPath && hasDataRef {
|
||||
return "", common.ValidationErrorf("html5-block cannot contain both path and data-ref").WithParam("html5-block")
|
||||
}
|
||||
if hasDataRef {
|
||||
ref := strings.TrimSpace(dataRef)
|
||||
if ref == "" {
|
||||
return "", common.ValidationErrorf("html5-block data-ref cannot be empty").WithParam("data-ref")
|
||||
}
|
||||
if _, ok := refMap[html5BlockTag][ref]; !ok {
|
||||
return "", common.ValidationErrorf("reference_map.%s.%s is required for html5-block data-ref", html5BlockTag, ref).WithParam("reference_map")
|
||||
}
|
||||
return tag.render(false), nil
|
||||
}
|
||||
if !hasPath {
|
||||
return "", common.ValidationErrorf("html5-block requires path=\"@relative.html\" or data-ref with reference_map").WithParam("html5-block")
|
||||
}
|
||||
|
||||
data, err := readHTML5BlockPath(runtime, pathValue, "html5-block path")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ref := nextRef()
|
||||
refMap[html5BlockTag][ref] = html5BlockReferenceEntry{Data: data}
|
||||
tag.removeAttrs(html5BlockPathAttr, html5BlockDataRefAttr, html5BlockDataAttr)
|
||||
tag.Attrs = append(tag.Attrs, html5BlockAttr{Name: html5BlockDataRefAttr, Value: ref})
|
||||
return tag.render(false), nil
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
out string
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(format) == "markdown" {
|
||||
out = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if err != nil {
|
||||
return segment
|
||||
}
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
err = rewriteErr
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
} else {
|
||||
out, err = rewrite(content)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(segment[match[2]:match[3]]) != "" {
|
||||
return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between <html5-block> and </html5-block>").WithParam("html5-block")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return validateSegment(content)
|
||||
}
|
||||
|
||||
var validateErr error
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if validateErr != nil {
|
||||
return segment
|
||||
}
|
||||
validateErr = validateSegment(segment)
|
||||
return segment
|
||||
})
|
||||
return validateErr
|
||||
}
|
||||
|
||||
func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
content, _ := doc["content"].(string)
|
||||
if !hasProcessableHTML5Block(format, content) {
|
||||
return nil
|
||||
}
|
||||
|
||||
refMap, err := referenceMapFromDocument(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group := refMap[html5BlockTag]
|
||||
if group == nil {
|
||||
return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map")
|
||||
}
|
||||
|
||||
if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for ref, entry := range group {
|
||||
if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw {
|
||||
continue
|
||||
}
|
||||
relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Data = ""
|
||||
entry.Path = "@" + filepath.ToSlash(relPath)
|
||||
group[ref] = entry
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
doc["reference_map"] = refMap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) {
|
||||
raw, ok := doc["reference_map"]
|
||||
if !ok || raw == nil {
|
||||
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
|
||||
}
|
||||
refMap, err := referenceMapFromValue(raw, "document.reference_map")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(refMap) == 0 {
|
||||
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) {
|
||||
if typed, ok := value.(html5BlockReferenceMap); ok {
|
||||
return compactReferenceMap(typed), nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err)
|
||||
}
|
||||
return parseHTML5BlockReferenceMapBytes(raw, label)
|
||||
}
|
||||
|
||||
func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error {
|
||||
validateSegment := func(segment string) error {
|
||||
_, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
|
||||
tag, parseErr := parseHTML5BlockStartTag(raw)
|
||||
if parseErr != nil {
|
||||
return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block")
|
||||
}
|
||||
ref, ok := tag.attr(html5BlockDataRefAttr)
|
||||
if !ok || strings.TrimSpace(ref) == "" {
|
||||
return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block")
|
||||
}
|
||||
ref = strings.TrimSpace(ref)
|
||||
if _, ok := refMap[html5BlockTag][ref]; !ok {
|
||||
return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map")
|
||||
}
|
||||
return raw, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return validateSegment(content)
|
||||
}
|
||||
var validateErr error
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if validateErr != nil {
|
||||
return segment
|
||||
}
|
||||
validateErr = validateSegment(segment)
|
||||
return segment
|
||||
})
|
||||
return validateErr
|
||||
}
|
||||
|
||||
func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error {
|
||||
for typ, group := range refMap {
|
||||
for ref, entry := range group {
|
||||
if strings.TrimSpace(entry.Path) == "" {
|
||||
continue
|
||||
}
|
||||
if entry.Data != "" {
|
||||
return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map")
|
||||
}
|
||||
data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Data = data
|
||||
entry.Path = ""
|
||||
group[ref] = entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path")
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(clean)) != ".html" {
|
||||
return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func hasProcessableHTML5Block(format string, content string) bool {
|
||||
if !strings.Contains(content, "<html5-block") {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return true
|
||||
}
|
||||
found := false
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if strings.Contains(segment, "<html5-block") {
|
||||
found = true
|
||||
}
|
||||
return segment
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func applyOutsideCodeFences(content string, fn func(segment string) string) string {
|
||||
var out strings.Builder
|
||||
var segment strings.Builder
|
||||
inFence := false
|
||||
|
||||
flush := func() {
|
||||
if segment.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out.WriteString(fn(segment.String()))
|
||||
segment.Reset()
|
||||
}
|
||||
|
||||
for _, line := range strings.SplitAfter(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
if !inFence {
|
||||
flush()
|
||||
inFence = true
|
||||
} else {
|
||||
inFence = false
|
||||
}
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
out.WriteString(line)
|
||||
} else {
|
||||
segment.WriteString(line)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func cloneReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(html5BlockReferenceMap, len(refMap))
|
||||
for typ, group := range refMap {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
outGroup := make(map[string]html5BlockReferenceEntry, len(group))
|
||||
for ref, entry := range group {
|
||||
outGroup[ref] = entry
|
||||
}
|
||||
out[typ] = outGroup
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneReferenceMapObject(refMap map[string]interface{}) map[string]interface{} {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(refMap))
|
||||
for key, value := range refMap {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func html5ReferenceMapFromObject(refMap map[string]interface{}) (html5BlockReferenceMap, error) {
|
||||
if len(refMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
group, ok := refMap[html5BlockTag]
|
||||
if !ok || group == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return referenceMapFromValue(map[string]interface{}{html5BlockTag: group}, "reference_map."+html5BlockTag)
|
||||
}
|
||||
|
||||
func mergeHTML5ReferenceMap(refMap map[string]interface{}, html5RefMap html5BlockReferenceMap) map[string]interface{} {
|
||||
group := html5RefMap[html5BlockTag]
|
||||
if len(group) == 0 {
|
||||
return refMap
|
||||
}
|
||||
if refMap == nil {
|
||||
refMap = map[string]interface{}{}
|
||||
}
|
||||
refMap[html5BlockTag] = group
|
||||
return refMap
|
||||
}
|
||||
|
||||
func compactReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(html5BlockReferenceMap, len(refMap))
|
||||
for typ, group := range refMap {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
out[typ] = group
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureReferenceGroup(refMap html5BlockReferenceMap, typ string) {
|
||||
if refMap[typ] == nil {
|
||||
refMap[typ] = map[string]html5BlockReferenceEntry{}
|
||||
}
|
||||
}
|
||||
|
||||
func nextHTML5BlockRef(refMap html5BlockReferenceMap) func() string {
|
||||
next := 1
|
||||
return func() string {
|
||||
for {
|
||||
ref := fmt.Sprintf("html5_%d", next)
|
||||
next++
|
||||
if _, exists := refMap[html5BlockTag][ref]; !exists {
|
||||
return ref
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeHTML5BlockReferenceFile(runtime *common.RuntimeContext, docToken string, ref string, html string) (string, error) {
|
||||
if !isSafeHTML5BlockResourceName(docToken) {
|
||||
return "", common.ValidationErrorf("document_id %q cannot be used as a resource directory name", docToken).WithParam("document_id")
|
||||
}
|
||||
if !isSafeHTML5BlockResourceName(ref) {
|
||||
return "", common.ValidationErrorf("html5-block data-ref %q cannot be used as a file name", ref).WithParam("data-ref")
|
||||
}
|
||||
relPath := filepath.Join(html5BlockReferenceRoot, docToken, ref+".html")
|
||||
data := []byte(html)
|
||||
_, err := runtime.FileIO().Save(relPath, fileio.SaveOptions{
|
||||
ContentType: "text/html; charset=utf-8",
|
||||
ContentLength: int64(len(data)),
|
||||
}, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return "", common.ValidationErrorf("cannot write html5-block reference file %q: %v", relPath, err).WithParam("reference_map").WithCause(err)
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot write html5-block reference file %q: %v", relPath, err).WithCause(err)
|
||||
}
|
||||
return relPath, nil
|
||||
}
|
||||
|
||||
func isSafeHTML5BlockResourceName(name string) bool {
|
||||
return name != "." && name != ".." && html5BlockSafeNamePattern.MatchString(name)
|
||||
}
|
||||
|
||||
func rewriteHTML5BlockStartTags(content string, fn func(raw string) (string, error)) (string, error) {
|
||||
var rewriteErr error
|
||||
out := html5BlockStartTagPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
if rewriteErr != nil {
|
||||
return raw
|
||||
}
|
||||
rewritten, err := fn(raw)
|
||||
if err != nil {
|
||||
rewriteErr = err
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if rewriteErr != nil {
|
||||
return "", rewriteErr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing := strings.HasSuffix(trimmed, "/>")
|
||||
decoder := xml.NewDecoder(strings.NewReader(raw))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return html5BlockStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != html5BlockTag {
|
||||
return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
attrs := make([]html5BlockAttr, 0, len(start.Attr))
|
||||
for _, attr := range start.Attr {
|
||||
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
|
||||
}
|
||||
return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
||||
remove := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
remove[name] = struct{}{}
|
||||
}
|
||||
attrs := t.Attrs[:0]
|
||||
for _, attr := range t.Attrs {
|
||||
if _, ok := remove[attr.Name]; ok {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(html5BlockTag)
|
||||
for _, attr := range t.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(attr.Name)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeXMLAttr(attr.Value))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if selfClosing {
|
||||
b.WriteString("/>")
|
||||
} else {
|
||||
b.WriteByte('>')
|
||||
}
|
||||
if t.SelfClosing && !selfClosing {
|
||||
b.WriteString("</")
|
||||
b.WriteString(html5BlockTag)
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
case '>':
|
||||
b.WriteString(">")
|
||||
case '"':
|
||||
b.WriteString(""")
|
||||
case '\'':
|
||||
b.WriteString("'")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
563
shortcuts/doc/html5_block_resources_test.go
Normal file
563
shortcuts/doc/html5_block_resources_test.go
Normal file
@@ -0,0 +1,563 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
flag := findDocsTestFlag(flags, "reference-map")
|
||||
if flag.Name == "" {
|
||||
t.Fatal("reference-map flag not found")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("reference-map flag should be public")
|
||||
}
|
||||
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "@reference-map.json") {
|
||||
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "input" {
|
||||
t.Fatalf("%s should not expose input flag", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"command": "append",
|
||||
"content": `<p><widget data-ref="r1"></widget></p>`,
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
})
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
|
||||
}
|
||||
|
||||
refMap, ok := body["reference_map"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
|
||||
}
|
||||
widget, _ := refMap["widget"].(map[string]interface{})
|
||||
r1, _ := widget["r1"].(map[string]interface{})
|
||||
if got := r1["label"]; got != "widget-ref-value" {
|
||||
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<html><body>hello</body></html>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<title>demo</title><html5-block path="@widget.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content was not rewritten with data-ref: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>hello</body></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
if _, ok := body["resources"]; ok {
|
||||
t.Fatalf("request body must not use resources: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
return common.Flag{}
|
||||
}
|
||||
|
||||
func hasDocsTestInput(flag common.Flag, input string) bool {
|
||||
for _, item := range flag.Input {
|
||||
if item == input {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<section>updated</section>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
|
||||
stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"revision_id": float64(2),
|
||||
"new_blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": "html5-block",
|
||||
"block_id": "blk_html5",
|
||||
"block_token": "boardXXXX",
|
||||
},
|
||||
},
|
||||
},
|
||||
"result": "success",
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsUpdate, []string{
|
||||
"+update",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_doc",
|
||||
"--command", "append",
|
||||
"--content", `<html5-block path="@widget.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<section>updated</section>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
|
||||
t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": "<html><main>fetched</main></html>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"tips": "must_read_html_code",
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
|
||||
if _, err := os.Stat(written); err == nil {
|
||||
t.Fatalf("small html should stay inline, got file %s", written)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got := doc["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content should keep data-ref: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><main>fetched</main></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
if _, ok := doc["resources"]; ok {
|
||||
t.Fatalf("fetch output must not use resources: %#v", doc)
|
||||
}
|
||||
if _, ok := data["suggestions"]; ok {
|
||||
t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
|
||||
}
|
||||
if got := data["tips"]; got != "must_read_html_code" {
|
||||
t.Fatalf("tips should be preserved from service response, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
largeHTML := "<html><main>" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "</main></html>"
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": largeHTML},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
|
||||
raw, err := os.ReadFile(written)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error: %v", written, err)
|
||||
}
|
||||
if string(raw) != largeHTML {
|
||||
t.Fatalf("materialized html = %q", raw)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
|
||||
t.Fatalf("content should keep data-ref and not path: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
|
||||
entry := refMap[html5BlockTag]["html5_1"]
|
||||
if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
|
||||
t.Fatalf("large html should be represented as path, got %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--reference-map", `{"html5-block":{"html5_1":{"data":"<html></html>"}}}`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"<html>from file</html>"}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(reference-map.json) error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--reference-map", "@reference-map.json",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html>from file</html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
|
||||
t.Fatalf("expected missing reference_map error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data="PGh0bWw+PC9odG1sPg=="></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
|
||||
t.Fatalf("expected internal data attr error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block path="@missing.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
|
||||
t.Fatalf("expected path read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<section>from file</section>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block path="@widget.html"><section>inline</section></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
|
||||
t.Fatalf("expected inline content error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_missing"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": "<html></html>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
|
||||
t.Fatalf("expected missing reference_map error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
|
||||
for _, fence := range []string{"```", "~~~"} {
|
||||
t.Run(fence, func(t *testing.T) {
|
||||
content := fence + "xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n" + fence + "\n"
|
||||
if hasProcessableHTML5Block("markdown", content) {
|
||||
t.Fatalf("html5-block inside markdown code fence should be ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
|
||||
runtime := newFetchShortcutTestRuntime(t, "", nil)
|
||||
tests := []struct {
|
||||
name string
|
||||
docToken string
|
||||
ref string
|
||||
want string
|
||||
}{
|
||||
{name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
|
||||
{name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
|
||||
{name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
|
||||
{name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "<html></html>")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<html><body>markdown</body></html>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "markdown",
|
||||
"--content", "before\n<html5-block path=\"@widget.html\"></html5-block>\nafter",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content was not rewritten: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>markdown</body></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
|
||||
t.Fatalf("decode request body: %v\n%s", err, raw)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
|
||||
}
|
||||
var refMap html5BlockReferenceMap
|
||||
if err := json.Unmarshal(data, &refMap); err != nil {
|
||||
t.Fatalf("decode reference_map: %v\n%s", err, data)
|
||||
}
|
||||
return refMap
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -17,6 +18,13 @@ const (
|
||||
secureLabelUpdateScope = "docs:secure_label:write_only"
|
||||
)
|
||||
|
||||
type secureLabelOperation string
|
||||
|
||||
const (
|
||||
secureLabelOperationList secureLabelOperation = "list"
|
||||
secureLabelOperationUpdate secureLabelOperation = "update"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
@@ -28,6 +36,9 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
Scopes: []string{secureLabelReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
"Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
@@ -53,7 +64,7 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return decorateSecureLabelError(err, secureLabelOperationList)
|
||||
}
|
||||
runtime.OutFormat(data, nil, nil)
|
||||
return nil
|
||||
@@ -68,13 +79,21 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{secureLabelUpdateScope},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
|
||||
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
|
||||
"When updating many files, serialize requests and back off on rate_limit errors.",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
|
||||
{Name: "label-id", Desc: "secure label ID to set", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -82,11 +101,15 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Update Drive secure label").
|
||||
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
|
||||
Body(map[string]interface{}{"id": labelID}).
|
||||
Set("file_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -94,14 +117,18 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": runtime.Str("label-id")}
|
||||
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": labelID}
|
||||
data, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return decorateSecureLabelError(err, secureLabelOperationUpdate)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
@@ -122,3 +149,70 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
}
|
||||
|
||||
// normalizeSecureLabelID trims a label id and rejects display names before the
|
||||
// request reaches Drive, where they otherwise surface as opaque JSON errors.
|
||||
func normalizeSecureLabelID(raw string) (string, error) {
|
||||
labelID := strings.TrimSpace(raw)
|
||||
if labelID == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required").
|
||||
WithParam("--label-id")
|
||||
}
|
||||
for _, r := range labelID {
|
||||
if r < '0' || r > '9' {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw).
|
||||
WithParam("--label-id").
|
||||
WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`")
|
||||
}
|
||||
}
|
||||
return labelID, nil
|
||||
}
|
||||
|
||||
// decorateSecureLabelError appends command-aware recovery guidance while
|
||||
// preserving upstream/classifier hints already attached to the typed error.
|
||||
func decorateSecureLabelError(err error, operation secureLabelOperation) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
guidance := secureLabelErrorGuidance(p.Code, operation)
|
||||
if guidance == "" {
|
||||
return err
|
||||
}
|
||||
if p.Hint == "" {
|
||||
p.Hint = guidance
|
||||
} else if !strings.Contains(p.Hint, guidance) {
|
||||
p.Hint = p.Hint + "; " + guidance
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// secureLabelErrorGuidance returns recovery guidance for secure-label API
|
||||
// failures whose generic code-level classification needs command context.
|
||||
func secureLabelErrorGuidance(code int, operation secureLabelOperation) string {
|
||||
switch code {
|
||||
case 99991400:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates"
|
||||
}
|
||||
return "secure label listing is rate limited; retry later with exponential backoff"
|
||||
case 1063013:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying"
|
||||
}
|
||||
case 1063002:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission"
|
||||
}
|
||||
return "the current user lacks permission to list secure labels; use a user with security-label read permission"
|
||||
case 1063001, 99992402, 9499:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name"
|
||||
}
|
||||
return "check secure label list parameters such as --page-size, --page-token, and --lang"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
@@ -90,13 +92,54 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
|
||||
Status: 429,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "rate limit exceeded",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server says slow down"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
|
||||
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
|
||||
}
|
||||
for _, want := range []string{"server says slow down", "secure label listing is rate limited"} {
|
||||
if !strings.Contains(apiErr.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, apiErr.Hint)
|
||||
}
|
||||
}
|
||||
if strings.Contains(apiErr.Hint, "updates are rate limited") {
|
||||
t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--label-id", " 7217780879644737539 ",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -132,7 +175,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--label-id", " 7217780879644737539 ",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -148,7 +191,32 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "Public(D)",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected label id validation error")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--label-id" {
|
||||
t.Fatalf("Param = %q, want --label-id", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "+secure-label-list") {
|
||||
t.Fatalf("hint missing list guidance: %q", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
@@ -169,7 +237,78 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected 1063013 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
|
||||
t.Fatalf("expected raw API error message, got: %v", err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 {
|
||||
t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "approval") {
|
||||
t.Fatalf("hint missing approval guidance: %q", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 9499, "msg": "Invalid parameter type in json: id",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected 9499 error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 {
|
||||
t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem)
|
||||
}
|
||||
if !strings.Contains(apiErr.Hint, "+secure-label-list") {
|
||||
t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 429,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400, "msg": "rate limit exceeded",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
|
||||
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
|
||||
}
|
||||
if !strings.Contains(apiErr.Hint, "backoff") {
|
||||
t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
|
||||
want := []string{
|
||||
"+chat-create",
|
||||
"+chat-list",
|
||||
"+chat-members-list",
|
||||
"+chat-messages-list",
|
||||
"+chat-search",
|
||||
"+chat-update",
|
||||
|
||||
420
shortcuts/im/im_chat_members_list.go
Normal file
420
shortcuts/im/im_chat_members_list.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
|
||||
chatMembersListDefaultPageSize = 20
|
||||
chatMembersListMaxPageSize = 100
|
||||
// chatMembersListDefaultPageDelay throttles --page-all the same way the
|
||||
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
|
||||
// server-side member cap, where a large group drains many pages back to
|
||||
// back and could otherwise trip rate limits.
|
||||
chatMembersListDefaultPageDelay = 200
|
||||
)
|
||||
|
||||
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
|
||||
// returning users and bots in separate buckets (users[]/bots[]). It owns its
|
||||
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
|
||||
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
|
||||
// the response is multi-bucket — the generic --page-all merger is built for
|
||||
// single-array responses and would drop the bots[] bucket and the final-page
|
||||
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
|
||||
var ImChatMembersList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-members-list",
|
||||
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
|
||||
Risk: "read",
|
||||
// Declare the narrowest scope the API accepts so tokens carrying only
|
||||
// im:chat.members:read are honored (same rationale as +chat-list).
|
||||
Scopes: []string{"im:chat.members:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
|
||||
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
|
||||
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
||||
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
||||
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Default fetches a single page; pass --page-all to walk every page.",
|
||||
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
|
||||
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
if chatID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||
}
|
||||
if !strings.HasPrefix(chatID, "oc_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
}
|
||||
if n := runtime.Int("page-delay"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
|
||||
}
|
||||
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
dry := common.NewDryRunAPI()
|
||||
if chatMembersShouldAutoPaginate(runtime) {
|
||||
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||
}
|
||||
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
return dry.
|
||||
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
res, err := fetchChatMembers(ctx, runtime, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The truncation signal is the whole reason this is a dedicated shortcut:
|
||||
// surface it loudly so an agent never mistakes a capped list for a
|
||||
// complete one.
|
||||
if len(res.truncations) > 0 {
|
||||
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"users": res.users,
|
||||
"bots": res.bots,
|
||||
"truncations": res.truncations,
|
||||
"has_more": res.hasMore,
|
||||
"page_token": res.pageToken,
|
||||
}
|
||||
if res.userTotal != nil {
|
||||
outData["user_total"] = res.userTotal
|
||||
}
|
||||
if res.botTotal != nil {
|
||||
outData["bot_total"] = res.botTotal
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
|
||||
renderChatMembersPretty(w, chatID, res)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// chatMembersResult is the aggregated view across one or more pages.
|
||||
type chatMembersResult struct {
|
||||
users []interface{}
|
||||
bots []interface{}
|
||||
truncations []interface{}
|
||||
userTotal interface{}
|
||||
botTotal interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
}
|
||||
|
||||
// effectiveChatMembersPageSize resolves the page_size to request. When draining
|
||||
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
|
||||
// uses the maximum so a full walk takes the fewest round-trips. An explicit
|
||||
// --page-size is always honored; without --page-all the smaller default is kept
|
||||
// as a sensible single-page preview size.
|
||||
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
|
||||
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
|
||||
return chatMembersListMaxPageSize
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
return n
|
||||
}
|
||||
return chatMembersListDefaultPageSize
|
||||
}
|
||||
|
||||
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
|
||||
// every page. An explicit --page-token disables the auto loop because the
|
||||
// caller supplied a specific cursor (single-page fetch).
|
||||
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" {
|
||||
return false
|
||||
}
|
||||
return runtime.Bool("page-all")
|
||||
}
|
||||
|
||||
// buildChatMembersParams builds the query params for one page request. The
|
||||
// startToken (when non-empty) seeds the page_token; the loop overrides it per
|
||||
// page. Returns the params and the normalized member-types CSV (already
|
||||
// validated by Validate, so the error is only a defensive guard).
|
||||
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
|
||||
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"member_id_type": runtime.Str("member-id-type"),
|
||||
"page_size": effectiveChatMembersPageSize(runtime),
|
||||
}
|
||||
if memberTypes != "" {
|
||||
params["member_types"] = memberTypes
|
||||
}
|
||||
if startToken != "" {
|
||||
params["page_token"] = startToken
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// fetchChatMembers walks the list_members endpoint, honoring the four
|
||||
// pagination flags the same way the generic --page-all path does. It merges
|
||||
// each page into the aggregate as it arrives (rather than buffering every raw
|
||||
// page), so peak memory is just the aggregated members plus the single most
|
||||
// recent page — important for large groups under --page-limit 0.
|
||||
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
|
||||
auto := chatMembersShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
pageDelay := runtime.Int("page-delay")
|
||||
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
|
||||
|
||||
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := newChatMembersResult()
|
||||
var lastData map[string]interface{}
|
||||
pageToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
for page := 0; ; page++ {
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addMemberBuckets(res, data)
|
||||
lastData = data
|
||||
|
||||
hasMore, nextToken := common.PaginationMeta(data)
|
||||
if !auto {
|
||||
break
|
||||
}
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
if nextToken == pageToken {
|
||||
// Guard against a buggy server echoing the same cursor with
|
||||
// has_more=true: without --page-limit we would loop forever.
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
|
||||
break
|
||||
}
|
||||
if pageLimit > 0 && page+1 >= pageLimit {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
// Throttle between pages (only reached when another page follows), so
|
||||
// draining a large untruncated list doesn't hammer the API.
|
||||
if pageDelay > 0 {
|
||||
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if lastData != nil {
|
||||
applyLastPageSignals(res, lastData)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
|
||||
// JSON output always carries arrays (never null).
|
||||
func newChatMembersResult() *chatMembersResult {
|
||||
return &chatMembersResult{
|
||||
users: []interface{}{},
|
||||
bots: []interface{}{},
|
||||
truncations: []interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
|
||||
// Concatenating every bucket is what avoids dropping bots[] — the bug the
|
||||
// generic single-array --page-all merger would hit on this multi-bucket shape.
|
||||
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
|
||||
if u, ok := data["users"].([]interface{}); ok {
|
||||
res.users = append(res.users, u...)
|
||||
}
|
||||
if b, ok := data["bots"].([]interface{}); ok {
|
||||
res.bots = append(res.bots, b...)
|
||||
}
|
||||
}
|
||||
|
||||
// applyLastPageSignals copies the per-request signals from the FINAL page:
|
||||
// has_more / page_token / truncations / totals. These must come from the last
|
||||
// page, not page 1: truncations[] is emitted only on the final page (empty
|
||||
// earlier), so reading it sooner would hide a server-side cap; user_total /
|
||||
// bot_total are server-wide counts, and taking the final page's value keeps a
|
||||
// single, consistent source rather than a possibly-stale earlier count.
|
||||
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
|
||||
res.hasMore, res.pageToken = common.PaginationMeta(data)
|
||||
if t, ok := data["truncations"].([]interface{}); ok {
|
||||
res.truncations = t
|
||||
}
|
||||
res.userTotal = data["user_total"]
|
||||
res.botTotal = data["bot_total"]
|
||||
}
|
||||
|
||||
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
|
||||
// the same logic fetchChatMembers applies incrementally, kept as a pure
|
||||
// function so the multi-bucket merge + last-page-signal semantics are unit
|
||||
// tested in one place.
|
||||
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
|
||||
res := newChatMembersResult()
|
||||
if len(pages) == 0 {
|
||||
return res
|
||||
}
|
||||
for _, data := range pages {
|
||||
addMemberBuckets(res, data)
|
||||
}
|
||||
applyLastPageSignals(res, pages[len(pages)-1])
|
||||
return res
|
||||
}
|
||||
|
||||
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
|
||||
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
|
||||
// the API's default of all types). Any element outside {user, bot} is rejected.
|
||||
func normalizeMemberTypes(raw []string) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p != "user" && p != "bot" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, ","), nil
|
||||
}
|
||||
|
||||
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
|
||||
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
|
||||
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
|
||||
}
|
||||
}
|
||||
|
||||
// writeChatMembersTruncationWarning emits a stderr warning for every
|
||||
// server-side bucket cap reported in truncations[]. It uses the repo's plain
|
||||
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
|
||||
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
|
||||
// pipes regardless of terminal encoding.
|
||||
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
|
||||
for _, t := range truncations {
|
||||
tm, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
memberType := valueOrAll(tm["member_type"])
|
||||
limit := tm["limit"]
|
||||
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrAll(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
|
||||
fmt.Fprintf(w, "Chat: %s\n", chatID)
|
||||
// Show the server-wide total next to the fetched count: when truncated or
|
||||
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
|
||||
// what tells the reader how incomplete the list is.
|
||||
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
|
||||
for i, u := range res.users {
|
||||
m, _ := u.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
|
||||
for i, b := range res.bots {
|
||||
m, _ := b.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
if len(res.truncations) > 0 {
|
||||
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
|
||||
}
|
||||
if res.hasMore {
|
||||
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
|
||||
if res.pageToken != "" {
|
||||
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDash(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// totalSuffix renders " of <total>" when the server-reported total exceeds the
|
||||
// number actually fetched (so a truncated/partial bucket is obvious), and ""
|
||||
// when the total is absent or already matches the fetched count.
|
||||
func totalSuffix(total interface{}, fetched int) string {
|
||||
n, ok := toInt(total)
|
||||
if !ok || n <= fetched {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" of %d", n)
|
||||
}
|
||||
|
||||
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
325
shortcuts/im/im_chat_members_list_test.go
Normal file
325
shortcuts/im/im_chat_members_list_test.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// page builds one list_members page payload shaped like the data object the
|
||||
// server returns (users[]/bots[]/truncations[] plus paging + totals).
|
||||
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"users": users,
|
||||
"bots": bots,
|
||||
"truncations": truncations,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
"user_total": 324,
|
||||
"bot_total": 2,
|
||||
}
|
||||
}
|
||||
|
||||
func us(ids ...string) []interface{} {
|
||||
out := make([]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]interface{}{"member_id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
|
||||
// (users AND bots) must be concatenated across pages, not just one of them.
|
||||
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 merged, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 3 {
|
||||
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
|
||||
// is emitted only on the final page, so the merged view must take it from the
|
||||
// last page rather than inherit page 1's empty slice.
|
||||
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
|
||||
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u2"), nil, limit, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.truncations) != 1 {
|
||||
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
|
||||
// signals come from the final page (so a --page-limit cutoff is visible).
|
||||
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), nil, nil, true, "p2"),
|
||||
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true from last page")
|
||||
}
|
||||
if res.pageToken != "p3" {
|
||||
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
|
||||
// are taken from the final page (not an earlier, possibly-different value).
|
||||
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
|
||||
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
|
||||
}
|
||||
res := mergeChatMemberPages(pages)
|
||||
if n, _ := toInt(res.userTotal); n != 324 {
|
||||
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
|
||||
}
|
||||
if n, _ := toInt(res.botTotal); n != 2 {
|
||||
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
|
||||
func TestChatMembersValidate(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
chatID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid oc_", "oc_abc", false},
|
||||
{"empty", "", true},
|
||||
{"missing oc_ prefix", "abc123", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
|
||||
err := ImChatMembersList.Validate(context.Background(), rt)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, c.name, err, "--chat-id")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationError checks err satisfies the repo's typed-error contract for
|
||||
// a validation failure: a *errs.ValidationError carrying the expected Param, and
|
||||
// problem metadata of category validation / subtype invalid_argument.
|
||||
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
|
||||
return
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMemberTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{nil, "", false},
|
||||
{[]string{"user", "bot"}, "user,bot", false},
|
||||
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
|
||||
{[]string{"admin"}, "", true},
|
||||
{[]string{""}, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := normalizeMemberTypes(c.in)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
|
||||
// drain with no explicit size → max; explicit size → honored; single page → default.
|
||||
func TestEffectiveChatMembersPageSize(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
b map[string]bool
|
||||
ints map[string]int
|
||||
want int
|
||||
}{
|
||||
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
|
||||
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
|
||||
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
|
||||
if got := effectiveChatMembersPageSize(rt); got != c.want {
|
||||
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newChatMembersTestRuntime registers the shortcut's flags and returns a
|
||||
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
|
||||
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
runtime := newUserShortcutRuntime(t, rt)
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("chat-id", "", "")
|
||||
cmd.Flags().String("member-id-type", "open_id", "")
|
||||
cmd.Flags().StringSlice("member-types", nil, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
for k, v := range str {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range b {
|
||||
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range ints {
|
||||
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
return runtime
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
|
||||
// fetch loop over mocked pages: users/bots merge across pages and the final
|
||||
// page's truncations[] survives.
|
||||
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
|
||||
calls := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
|
||||
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
|
||||
}
|
||||
calls++
|
||||
token := req.URL.Query().Get("page_token")
|
||||
if token == "" {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("want 2 page calls, got %d", calls)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 2 {
|
||||
t.Errorf("bots: want 2, got %d", len(res.bots))
|
||||
}
|
||||
if len(res.truncations) != 1 {
|
||||
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
|
||||
}
|
||||
if res.hasMore {
|
||||
t.Error("has_more: want false after draining all pages")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
|
||||
// leaves has_more=true so the caller knows the result is incomplete.
|
||||
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
|
||||
seq := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// Every page reports more pages available, with an advancing token so the
|
||||
// loop is stopped by --page-limit, not the non-advancing-token guard.
|
||||
seq++
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
|
||||
}
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true (loop cut short by page-limit)")
|
||||
}
|
||||
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "reached page limit (3)") {
|
||||
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMembersList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// buildContentBlock converts text and mentions to a ContentBlock.
|
||||
func buildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
|
||||
|
||||
// Add text element
|
||||
textElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
}
|
||||
elements = append(elements, textElem)
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range mentions {
|
||||
mentionElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &mention,
|
||||
},
|
||||
}
|
||||
elements = append(elements, mentionElem)
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
content := buildContentBlock(obj.Text, obj.Mention)
|
||||
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
|
||||
|
||||
// createKR calls the API to create a key result.
|
||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
||||
content := buildContentBlock(kr.Text, kr.Mention)
|
||||
content := BuildContentBlock(kr.Text, kr.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Objective creation
|
||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
||||
objContent := BuildContentBlock(obj.Text, obj.Mention)
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
// KR creations
|
||||
for j, kr := range obj.KRs {
|
||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
||||
krContent := BuildContentBlock(kr.Text, kr.Mention)
|
||||
krBody := map[string]interface{}{
|
||||
"content": krContent,
|
||||
}
|
||||
|
||||
@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
|
||||
|
||||
func TestBuildContentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
|
||||
@@ -29,15 +29,10 @@ type RespCategory struct {
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
ID string `json:"id"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
@@ -152,3 +147,145 @@ type RespProgress struct {
|
||||
Content *string `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ========== Simple-style response types (semi-plain text format) ==========
|
||||
|
||||
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespKeyResultSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespObjectiveSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *SemiPlainContent `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespProgressSimple struct {
|
||||
ID string `json:"progress_id"`
|
||||
ModifyTime string `json:"modify_time"`
|
||||
CreateTime *string `json:"create_time,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ToSimple converts KeyResult to RespKeyResultSimple.
|
||||
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResultSimple{
|
||||
ID: k.ID,
|
||||
CreateTime: formatTimestamp(k.CreateTime),
|
||||
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||
Owner: *k.Owner.ToResp(),
|
||||
ObjectiveID: k.ObjectiveID,
|
||||
Position: k.Position,
|
||||
Score: k.Score,
|
||||
Weight: k.Weight,
|
||||
}
|
||||
if k.Deadline != nil {
|
||||
d := formatTimestamp(*k.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = k.Content.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts Objective to RespObjectiveSimple.
|
||||
func (o *Objective) ToSimple() *RespObjectiveSimple {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjectiveSimple{
|
||||
ID: o.ID,
|
||||
CreateTime: formatTimestamp(o.CreateTime),
|
||||
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||
Owner: *o.Owner.ToResp(),
|
||||
CycleID: o.CycleID,
|
||||
Position: o.Position,
|
||||
Score: o.Score,
|
||||
Weight: o.Weight,
|
||||
CategoryID: o.CategoryID,
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
d := formatTimestamp(*o.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = o.Content.ToSemiPlain()
|
||||
result.Notes = o.Notes.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts ProgressV1 to RespProgressSimple.
|
||||
func (p *ProgressV1) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.ModifyTime),
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.Percent,
|
||||
}
|
||||
if p.ProgressRate.Status != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.Status).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Content != nil {
|
||||
resp.Content = p.Content.ToV2().ToSemiPlain()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToSimple converts Progress to RespProgressSimple.
|
||||
func (p *Progress) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
createTime := formatTimestamp(p.CreateTime)
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.UpdateTime),
|
||||
CreateTime: &createTime,
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.ProgressPercent,
|
||||
}
|
||||
if p.ProgressRate.ProgressStatus != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Content = p.Content.ToSemiPlain()
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
@@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
style := runtime.Str("style")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
@@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := map[string]interface{}{"page_size": "100"}
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if style == "simple" {
|
||||
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
obj := &objectives[i]
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
respObj := obj.ToSimple()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToSimple(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
contentText := ""
|
||||
if o.Content != nil {
|
||||
contentText = o.Content.Text
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
notesText := ""
|
||||
if o.Notes != nil {
|
||||
notesText = o.Notes.Text
|
||||
}
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
krText := ""
|
||||
if kr.Content != nil {
|
||||
krText = kr.Content.Text
|
||||
}
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// richtext mode
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
krQuery["page_token"] = pageToken
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
// isCurrentActiveCycle checks whether a cycle is currently active:
|
||||
// - current time is within the cycle's start and end time
|
||||
// - cycle status is default (0) or normal (1)
|
||||
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
nowUTC := now.UTC()
|
||||
|
||||
// Check time range: now must be >= start and <= end
|
||||
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check status: must be default or normal
|
||||
if cycle.CycleStatus == nil {
|
||||
return false
|
||||
}
|
||||
status := *cycle.CycleStatus
|
||||
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
// Filter current active cycles
|
||||
now := time.Now()
|
||||
currentActiveCycles := make([]*RespCycle, 0)
|
||||
for i := range filtered {
|
||||
if isCurrentActiveCycle(&filtered[i], now) {
|
||||
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"current_active_cycles": currentActiveCycles,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
if len(currentActiveCycles) > 0 {
|
||||
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
|
||||
for _, c := range currentActiveCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -5,8 +5,10 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
// Assert current_active_cycles field exists and is a slice
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 0 {
|
||||
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
|
||||
}
|
||||
}
|
||||
|
||||
// --- isCurrentActiveCycle unit tests ---
|
||||
|
||||
func TestIsCurrentActiveCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cycle *Cycle
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "active cycle with normal status",
|
||||
cycle: &Cycle{
|
||||
ID: "c1",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31 23:59:59
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "active cycle with default status",
|
||||
cycle: &Cycle{
|
||||
ID: "c2",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusDefault.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "cycle with invalid status",
|
||||
cycle: &Cycle{
|
||||
ID: "c3",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusInvalid.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cycle with hidden status",
|
||||
cycle: &Cycle{
|
||||
ID: "c4",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusHidden.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "past cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c5",
|
||||
StartTime: "1704067200000", // 2024-01-01
|
||||
EndTime: "1719791999999", // 2024-06-30
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "future cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c6",
|
||||
StartTime: "1830297600000", // 2028-01-01
|
||||
EndTime: "1861833599999", // 2028-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil cycle status",
|
||||
cycle: &Cycle{
|
||||
ID: "c7",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: nil,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid start time",
|
||||
cycle: &Cycle{
|
||||
ID: "c8",
|
||||
StartTime: "invalid",
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "exact start time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c9",
|
||||
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact end time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c10",
|
||||
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
|
||||
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isCurrentActiveCycle(tt.cycle, now)
|
||||
if result != tt.expected {
|
||||
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Calculate timestamps relative to now to avoid test expiration
|
||||
now := time.Now().UTC()
|
||||
// Active cycle: 6 months before to 6 months after now
|
||||
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
|
||||
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
|
||||
// Past cycle: 2 years before to 1.5 years before now
|
||||
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
|
||||
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"id": "cycle-active",
|
||||
"start_time": strconv.FormatInt(activeStartMs, 10),
|
||||
"end_time": strconv.FormatInt(activeEndMs, 10),
|
||||
"cycle_status": 1, // normal
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"id": "cycle-past",
|
||||
"start_time": strconv.FormatInt(pastStartMs, 10),
|
||||
"end_time": strconv.FormatInt(pastEndMs, 10),
|
||||
"cycle_status": 2, // invalid
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
|
||||
// Check current_active_cycles - should only contain cycle-active
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 1 {
|
||||
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
|
||||
}
|
||||
activeCycle, ok := currentActive[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
|
||||
}
|
||||
if activeCycle["id"] != "cycle-active" {
|
||||
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
|
||||
}
|
||||
|
||||
// Verify removed fields are not present in the response
|
||||
for _, c := range cycles {
|
||||
cycleMap, _ := c.(map[string]interface{})
|
||||
if _, ok := cycleMap["create_time"]; ok {
|
||||
t.Fatal("create_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["update_time"]; ok {
|
||||
t.Fatal("update_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["tenant_cycle_id"]; ok {
|
||||
t.Fatal("tenant_cycle_id should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["owner"]; ok {
|
||||
t.Fatal("owner should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["score"]; ok {
|
||||
t.Fatal("score should not be present in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,9 @@ package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
ID: c.ID,
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
@@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SemiPlainContent (半纯文本格式) ==========
|
||||
|
||||
// Regex patterns for semi-plain text processing (pre-compiled for performance).
|
||||
var (
|
||||
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
|
||||
multiSpaceRE = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// SemiPlainDoc represents a document link in semi-plain content.
|
||||
type SemiPlainDoc struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
|
||||
// It contains plain text, mentions, docs, and images without rich formatting or position info.
|
||||
type SemiPlainContent struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
Docs []SemiPlainDoc `json:"docs,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
|
||||
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
|
||||
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
result := &SemiPlainContent{}
|
||||
var textParts []string
|
||||
|
||||
for _, block := range c.Blocks {
|
||||
if block.Paragraph != nil {
|
||||
for _, elem := range block.Paragraph.Elements {
|
||||
switch {
|
||||
case elem.TextRun != nil && elem.TextRun.Text != nil:
|
||||
textParts = append(textParts, *elem.TextRun.Text)
|
||||
case elem.Mention != nil && elem.Mention.UserID != nil:
|
||||
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
|
||||
result.Mention = append(result.Mention, *elem.Mention.UserID)
|
||||
case elem.DocsLink != nil:
|
||||
doc := SemiPlainDoc{}
|
||||
if elem.DocsLink.Title != nil {
|
||||
doc.Title = *elem.DocsLink.Title
|
||||
}
|
||||
if elem.DocsLink.URL != nil {
|
||||
doc.URL = *elem.DocsLink.URL
|
||||
}
|
||||
result.Docs = append(result.Docs, doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if block.Gallery != nil {
|
||||
for _, img := range block.Gallery.Images {
|
||||
if img.Src != nil {
|
||||
result.Images = append(result.Images, *img.Src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Text = strings.Join(textParts, "")
|
||||
return result
|
||||
}
|
||||
|
||||
// ToContentBlock converts SemiPlainContent to ContentBlock.
|
||||
// Text and mentions are placed in a single paragraph (text first, then mentions).
|
||||
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
|
||||
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
|
||||
|
||||
// Strip @{userID} placeholders from text to avoid duplicate mentions
|
||||
// (these placeholders are only for readability in the output format)
|
||||
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
|
||||
// Collapse multiple spaces and trim
|
||||
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
|
||||
strippedText = strings.TrimSpace(strippedText)
|
||||
|
||||
// Add text element if stripped text is not empty
|
||||
if strippedText != "" {
|
||||
text := strippedText
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range s.Mention {
|
||||
m := mention
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &m,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContentBlock converts text and mentions to a ContentBlock.
|
||||
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
|
||||
func BuildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
return (&SemiPlainContent{
|
||||
Text: text,
|
||||
Mention: mentions,
|
||||
}).ToContentBlock()
|
||||
}
|
||||
|
||||
// ProgressRateV1 进度率
|
||||
type ProgressRateV1 struct {
|
||||
Percent *float64 `json:"percent,omitempty"`
|
||||
|
||||
@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
// Verify removed fields are not present in RespCycle
|
||||
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
|
||||
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
|
||||
// boolPtr returns a pointer to the given bool value.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// ========== SemiPlainContent Conversion Tests ==========
|
||||
|
||||
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello world"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Hello world" {
|
||||
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 0 {
|
||||
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
|
||||
}
|
||||
if len(sp.Docs) != 0 {
|
||||
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
|
||||
}
|
||||
if len(sp.Images) != 0 {
|
||||
t.Fatalf("expected 0 images, got %d", len(sp.Images))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: strPtr("ou_123"),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr(", how are you?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
// Text includes @{userID} placeholder to preserve positional context
|
||||
if sp.Text != "Hello @{ou_123} , how are you?" {
|
||||
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
|
||||
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Check out this doc: "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
|
||||
DocsLink: &ContentDocsLink{
|
||||
Title: strPtr("Design Doc"),
|
||||
URL: strPtr("https://example.feishu.cn/docx/xxx"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
BlockElementType: BlockElementTypeGallery.Ptr(),
|
||||
Gallery: &ContentGallery{
|
||||
Images: []ContentImageItem{
|
||||
{
|
||||
Src: strPtr("https://example.com/img1.png"),
|
||||
},
|
||||
{
|
||||
Src: strPtr("https://example.com/img2.png"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Check out this doc: " {
|
||||
t.Fatalf("unexpected text: '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Docs) != 1 {
|
||||
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
|
||||
}
|
||||
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
|
||||
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
|
||||
}
|
||||
if len(sp.Images) != 2 {
|
||||
t.Fatalf("expected 2 images, got %d", len(sp.Images))
|
||||
}
|
||||
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
|
||||
t.Fatalf("unexpected images: %v", sp.Images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var cb *ContentBlock
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp != nil {
|
||||
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Hello world",
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
block := cb.Blocks[0]
|
||||
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
|
||||
t.Fatal("expected paragraph block")
|
||||
}
|
||||
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
|
||||
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
|
||||
}
|
||||
elem := block.Paragraph.Elements[0]
|
||||
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected textRun element")
|
||||
}
|
||||
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
|
||||
t.Fatalf("unexpected text: %v", elem.TextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Please review",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
|
||||
t.Fatal("unexpected first element")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: " ",
|
||||
Mention: []string{"ou_123"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Empty text should be skipped, only mention remains
|
||||
if len(elems) != 1 {
|
||||
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected mention element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Test",
|
||||
Mention: []string{"ou_123"},
|
||||
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
|
||||
Images: []string{"https://img.png"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Docs and images are ignored in input conversion
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Simulate round-trip: output format has @{userID} in text,
|
||||
// input conversion should strip them to avoid duplicate mentions
|
||||
sp := &SemiPlainContent{
|
||||
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
|
||||
Mention: []string{"ou_zhangsan", "ou_lisi"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have 3 elements: 1 text (stripped) + 2 mentions
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
// Text should have placeholders stripped
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected first element to be textRun")
|
||||
}
|
||||
// Note: space before comma is preserved from the placeholder's trailing space
|
||||
expectedText := "任务一 ,任务二"
|
||||
if *elems[0].TextRun.Text != expectedText {
|
||||
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
|
||||
}
|
||||
// Mentions should be preserved as separate elements
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Text that is only placeholders should result in no text element
|
||||
sp := &SemiPlainContent{
|
||||
Text: " @{ou_123} @{ou_456} ",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have only 2 mention elements, no text element
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected first element to be mention")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected second element to be mention")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var sp *SemiPlainContent
|
||||
cb := sp.ToContentBlock()
|
||||
if cb != nil {
|
||||
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentBlock_Conversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements, got %d", len(elems))
|
||||
}
|
||||
if *elems[0].TextRun.Text != "Test text" {
|
||||
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
|
||||
}
|
||||
if *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
|
||||
}
|
||||
if *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSimpleMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test Objective.ToSimple()
|
||||
text := "Objective text"
|
||||
obj := &Objective{
|
||||
ID: "obj-1",
|
||||
Content: BuildContentBlock(text, []string{"ou_123"}),
|
||||
Notes: BuildContentBlock("Note text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
|
||||
CycleID: "cycle-1",
|
||||
Score: float64Ptr(0.7),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1735776000000"),
|
||||
}
|
||||
simpleObj := obj.ToSimple()
|
||||
if simpleObj == nil {
|
||||
t.Fatal("expected non-nil RespObjectiveSimple")
|
||||
}
|
||||
if simpleObj.ID != "obj-1" {
|
||||
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedContentText := "Objective text @{ou_123} "
|
||||
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
|
||||
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
|
||||
}
|
||||
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
|
||||
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
|
||||
}
|
||||
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
|
||||
t.Fatalf("unexpected score: %v", simpleObj.Score)
|
||||
}
|
||||
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
|
||||
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
|
||||
}
|
||||
|
||||
// Test KeyResult.ToSimple()
|
||||
kr := &KeyResult{
|
||||
ID: "kr-1",
|
||||
ObjectiveID: "obj-1",
|
||||
Content: BuildContentBlock("KR text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
|
||||
Score: float64Ptr(0.5),
|
||||
}
|
||||
simpleKR := kr.ToSimple()
|
||||
if simpleKR == nil {
|
||||
t.Fatal("expected non-nil RespKeyResultSimple")
|
||||
}
|
||||
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
|
||||
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
|
||||
}
|
||||
|
||||
// Test ProgressV1.ToSimple()
|
||||
progress := &ProgressV1{
|
||||
ID: "prog-1",
|
||||
ModifyTime: "1735776000000",
|
||||
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
|
||||
}
|
||||
simpleProgress := progress.ToSimple()
|
||||
if simpleProgress == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple")
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedProgressText := "Progress text @{ou_mention} "
|
||||
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
|
||||
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
|
||||
}
|
||||
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
|
||||
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
|
||||
}
|
||||
|
||||
// Test Progress.ToSimple() (V2 progress record)
|
||||
progressV2 := &Progress{
|
||||
ID: "prog-v2-1",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
|
||||
ProgressRate: &ProgressRate{
|
||||
ProgressPercent: float64Ptr(80.0),
|
||||
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
|
||||
},
|
||||
}
|
||||
simpleProgressV2 := progressV2.ToSimple()
|
||||
if simpleProgressV2 == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
|
||||
}
|
||||
if simpleProgressV2.ID != "prog-v2-1" {
|
||||
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
|
||||
}
|
||||
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
|
||||
t.Fatal("expected non-empty CreateTime for Progress V2")
|
||||
}
|
||||
expectedV2Text := "V2 progress text @{ou_v2_mention} "
|
||||
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
|
||||
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
|
||||
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
|
||||
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
|
||||
}
|
||||
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
|
||||
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
// listTypePtr returns a pointer to the given ListType value.
|
||||
func listTypePtr(v ListType) *ListType { return &v }
|
||||
|
||||
311
shortcuts/okr/okr_patch.go
Normal file
311
shortcuts/okr/okr_patch.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// patchParams holds the parsed parameters for the patch operation.
|
||||
type patchParams struct {
|
||||
Level string
|
||||
TargetID string
|
||||
Style string
|
||||
Content *ContentBlock
|
||||
Notes *ContentBlock
|
||||
Score *float64
|
||||
Deadline *string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
|
||||
p := &patchParams{
|
||||
Level: runtime.Str("level"),
|
||||
TargetID: runtime.Str("target-id"),
|
||||
Style: runtime.Str("style"),
|
||||
UserIDType: runtime.Str("user-id-type"),
|
||||
}
|
||||
|
||||
hasField := false
|
||||
|
||||
// Parse content if provided
|
||||
if contentStr := runtime.Str("content"); contentStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
p.Content = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
p.Content = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse notes if provided (only for objective)
|
||||
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
|
||||
}
|
||||
p.Notes = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
p.Notes = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse score if provided
|
||||
if scoreStr := runtime.Str("score"); scoreStr != "" {
|
||||
hasField = true
|
||||
score, err := strconv.ParseFloat(scoreStr, 64)
|
||||
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
|
||||
}
|
||||
if score < 0 || score > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
|
||||
}
|
||||
// Check for exactly one decimal place
|
||||
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
|
||||
parts := strings.Split(scoreStrTrimmed, ".")
|
||||
if len(parts) == 2 && len(parts[1]) > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
|
||||
}
|
||||
// Validation ensures at most one decimal place, so score is already correctly formatted
|
||||
p.Score = &score
|
||||
}
|
||||
|
||||
// Parse deadline if provided
|
||||
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
|
||||
hasField = true
|
||||
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
|
||||
}
|
||||
if deadlineMs <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
|
||||
}
|
||||
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
|
||||
// Anything less than 1e12 is likely seconds or a wrong unit
|
||||
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
|
||||
}
|
||||
p.Deadline = &deadlineStr
|
||||
}
|
||||
|
||||
// At least one field must be provided
|
||||
if !hasField {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// OKRPatch patches an objective or key result.
|
||||
var OKRPatch = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+patch",
|
||||
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
|
||||
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
if targetID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||
}
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
// Delegate content/notes/score/deadline validation to parsePatchParams
|
||||
if _, err := parsePatchParams(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("").
|
||||
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
api := common.NewDryRunAPI()
|
||||
if p.Level == "objective" {
|
||||
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
|
||||
Set("objective_id", p.TargetID)
|
||||
} else {
|
||||
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
|
||||
Set("key_result_id", p.TargetID)
|
||||
}
|
||||
return api.Params(params).Body(body).
|
||||
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
|
||||
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
var path string
|
||||
if p.Level == "objective" {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
|
||||
} else {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"level": p.Level,
|
||||
"target_id": p.TargetID,
|
||||
"patched": map[string]bool{
|
||||
"content": p.Content != nil,
|
||||
"notes": p.Notes != nil,
|
||||
"score": p.Score != nil,
|
||||
"deadline": p.Deadline != nil,
|
||||
},
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
|
||||
if p.Content != nil {
|
||||
fmt.Fprintf(w, " - content: updated\n")
|
||||
}
|
||||
if p.Notes != nil {
|
||||
fmt.Fprintf(w, " - notes: updated\n")
|
||||
}
|
||||
if p.Score != nil {
|
||||
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
1350
shortcuts/okr/okr_patch_test.go
Normal file
1350
shortcuts/okr/okr_patch_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
|
||||
|
||||
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
||||
style := runtime.Str("style")
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
var contentV1 *ContentBlockV1
|
||||
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
// Validate mention IDs are non-empty
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
// Build ContentBlock from semi-plain content (text + mentions)
|
||||
contentV1 = sp.ToContentBlock().ToV1()
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 = cb.ToV1()
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
targetType := runtime.Str("target-type")
|
||||
targetTypeVal := targetTypeAllowed[targetType]
|
||||
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||
{Name: "progress-percent", Desc: "progress percentage"},
|
||||
@@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
|
||||
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate content is valid JSON and can be parsed as ContentBlock
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
// Validate content based on style
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
// If user provided docs or images in simple mode, warn that they are ignored
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
|
||||
}
|
||||
|
||||
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
||||
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
@@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", "not-json",
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "abc",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "invalid",
|
||||
})
|
||||
@@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--user-id-type", "invalid",
|
||||
@@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "999999999999",
|
||||
@@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "abc",
|
||||
@@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-status", "invalid_status",
|
||||
@@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--dry-run",
|
||||
@@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "75",
|
||||
@@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "456",
|
||||
"--target-type", "key_result",
|
||||
})
|
||||
@@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "789",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple mode tests ---
|
||||
|
||||
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v1/progress_records/",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "300",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Use default style (simple) without specifying --style
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "300" {
|
||||
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v1/progress_records/",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "400",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Explicitly specify --style simple with mentions
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
|
||||
"--style", "simple",
|
||||
"--target-id", "456",
|
||||
"--target-type", "key_result",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "400" {
|
||||
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"missing closing brace`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid semi-plain JSON")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":" ","mention":[]}`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty text in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for docs in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "POST") {
|
||||
t.Fatalf("dry-run output should contain POST method, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
@@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
style := runtime.Str("style")
|
||||
|
||||
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
||||
|
||||
@@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
if len(resp.Content.Mention) > 0 {
|
||||
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -25,12 +26,35 @@ type updateProgressRecordParams struct {
|
||||
|
||||
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
|
||||
style := runtime.Str("style")
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
var contentV1 *ContentBlockV1
|
||||
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
contentV1 = sp.ToContentBlock().ToV1()
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 = cb.ToV1()
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
var progressRate *ProgressRateV1
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
@@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "progress-percent", Desc: "progress percentage"},
|
||||
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
@@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
// Validate content based on style
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
@@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --progress-id")
|
||||
@@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "abc",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --progress-id")
|
||||
@@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", "not-json",
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --content JSON")
|
||||
@@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-percent", "-999999999999",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-status", "invalid_status",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "456",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "456",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-percent", "50",
|
||||
"--progress-status", "overdue",
|
||||
"--dry-run",
|
||||
@@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "789",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "999",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple mode tests ---
|
||||
|
||||
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v1/progress_records/500",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "500",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Use default style (simple) without specifying --style
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "500",
|
||||
"--content", validSemiPlainJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "500" {
|
||||
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v1/progress_records/600",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "600",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Explicitly specify --style simple with mentions and progress rate
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "600",
|
||||
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
|
||||
"--style", "simple",
|
||||
"--progress-percent", "80",
|
||||
"--progress-status", "normal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "600" {
|
||||
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"invalid json`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid semi-plain JSON")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty mention in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for images in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "700",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "PUT") {
|
||||
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
OKRPatch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVGlide,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
|
||||
135
shortcuts/slides/slides_create_svglide.go
Normal file
135
shortcuts/slides/slides_create_svglide.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/svglide"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesCreateSVGlide manages a local Codex-mediated SVGlide SVG run directory.
|
||||
var SlidesCreateSVGlide = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create-svglide",
|
||||
Description: "Create and manage a local SVGlide SVG run directory",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "action", Desc: "runtime action: init, status, next, complete, author, validate, preview, repair", Required: true, Enum: []string{"init", "status", "next", "complete", "author", "validate", "preview", "repair"}},
|
||||
{Name: "run", Desc: "existing run directory for status/next/complete/author/validate/preview/repair"},
|
||||
{Name: "title", Desc: "deck title for init"},
|
||||
{Name: "input", Desc: "local source markdown/text path for init"},
|
||||
{Name: "audience", Desc: "final audience for the deck"},
|
||||
{Name: "delivery-mode", Desc: "delivery mode: presented, self_read, dual_mode", Enum: []string{"presented", "self_read", "dual_mode"}},
|
||||
{Name: "pages", Type: "int", Desc: "target page count"},
|
||||
{Name: "out", Desc: "output run directory for init"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "allow init to overwrite an existing run directory"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
if action == "init" {
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title is required for init").WithParam("--title")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("input")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input is required for init").WithParam("--input")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("out")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--out is required for init").WithParam("--out")
|
||||
}
|
||||
if stat, err := runtime.FileIO().Stat(runtime.Str("input")); err != nil {
|
||||
return common.WrapInputStatErrorTyped(err, "cannot read --input")
|
||||
} else if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be a regular file").WithParam("--input")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("run")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--run is required for %s", action).WithParam("--run")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
switch action {
|
||||
case "init":
|
||||
out := runtime.Str("out")
|
||||
if err := svglide.InitRun(out, svglide.InitOptions{
|
||||
Title: runtime.Str("title"),
|
||||
Input: runtime.Str("input"),
|
||||
Audience: runtime.Str("audience"),
|
||||
DeliveryMode: runtime.Str("delivery-mode"),
|
||||
Pages: runtime.Int("pages"),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := svglide.InspectStatus(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]any{
|
||||
"action": action,
|
||||
"run": out,
|
||||
"next_command": status.NextCommand,
|
||||
}, nil)
|
||||
return nil
|
||||
case "status":
|
||||
report, err := svglide.InspectStatus(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "next":
|
||||
report, err := svglide.NextTask(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "complete":
|
||||
report, err := svglide.CompleteCurrentStage(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "author":
|
||||
report, err := svglide.AuthorSlides(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "validate":
|
||||
report, err := svglide.ValidateRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "preview":
|
||||
report, err := svglide.WritePreview(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "repair":
|
||||
report, err := svglide.RepairRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --action %q", action).WithParam("--action")
|
||||
}
|
||||
},
|
||||
}
|
||||
381
shortcuts/slides/slides_create_svglide_test.go
Normal file
381
shortcuts/slides/slides_create_svglide_test.go
Normal file
@@ -0,0 +1,381 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSlidesCreateSVGlideInitShortcut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--audience", "产品负责人",
|
||||
"--delivery-mode", "self_read",
|
||||
"--pages", "8",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["action"] != "init" {
|
||||
t.Fatalf("action = %v, want init", data["action"])
|
||||
}
|
||||
if data["run"] != "run-demo" {
|
||||
t.Fatalf("run = %v, want run-demo", data["run"])
|
||||
}
|
||||
if !strings.Contains(stringValue(data["next_command"]), "--action next --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want next action", data["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRejectsPositionalAction(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"init",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected positional argument rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideStatusAndNextActions(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "status",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statusData := decodeShortcutData(t, stdout)
|
||||
if statusData["current_stage"] != "request" {
|
||||
t.Fatalf("current_stage = %v, want request", statusData["current_stage"])
|
||||
}
|
||||
if !strings.Contains(stringValue(statusData["next_command"]), "--action next --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want next action", statusData["next_command"])
|
||||
}
|
||||
|
||||
err = runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "next",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextData := decodeShortcutData(t, stdout)
|
||||
if nextData["stage"] != "request" || nextData["prompt_path"] != "prompts/01_request.task.md" {
|
||||
t.Fatalf("next data = %+v, want request prompt", nextData)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompts", "01_request.task.md")); err != nil {
|
||||
t.Fatalf("missing prompt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != true {
|
||||
t.Fatalf("ok = %v, want true; data=%+v", data["ok"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlidePreviewActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "preview",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatalf("missing preview receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideActionEnumIncludesCompleteAuthorAndRepair(t *testing.T) {
|
||||
actionFlag := findSVGlideShortcutFlag(t, "action")
|
||||
want := map[string]bool{
|
||||
"complete": false,
|
||||
"author": false,
|
||||
"repair": false,
|
||||
}
|
||||
for _, value := range actionFlag.Enum {
|
||||
if _, ok := want[value]; ok {
|
||||
want[value] = true
|
||||
}
|
||||
}
|
||||
for value, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("action enum missing %q: %+v", value, actionFlag.Enum)
|
||||
}
|
||||
if !strings.Contains(actionFlag.Desc, value) {
|
||||
t.Fatalf("action desc %q missing %q", actionFlag.Desc, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRepairActionAuthorsAndPreviews(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "repair",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if data["reauthored"] != true {
|
||||
t.Fatalf("reauthored = %v, want true; data=%+v", data["reauthored"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "validate_preview_repair.json")); err != nil {
|
||||
t.Fatalf("missing final repair receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideCompleteActionAdvancesRequestStage(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "complete",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["current_stage"] != "research" {
|
||||
t.Fatalf("current_stage = %v, want research; data=%+v", data["current_stage"], data)
|
||||
}
|
||||
receiptPath := filepath.Join(dir, "run-demo", "receipts", "request.json")
|
||||
raw, err := os.ReadFile(receiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt["stage"] != "request" || receipt["status"] != "done" {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideAuthorActionWritesSVG(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "author",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "done" {
|
||||
t.Fatalf("status = %v, want done; data=%+v", data["status"], data)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("run-demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing authored SVG: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `slide:role="slide"`) {
|
||||
t.Fatalf("authored SVG missing slide role:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionDoesNotErrorOnValidationFailure(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/missing.svg")
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != false {
|
||||
t.Fatalf("ok = %v, want false; data=%+v", data["ok"], data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRegistered(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Command == "+create-svglide" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("slides +create-svglide shortcut is not registered")
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithDeck(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "slides", "01.svg"), svglideShortcutVisibleTextSVG())
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithAuthorInputs(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "brief", "visual_system.json"), `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "content", "slide_content.json"), `{"slides":[{"id":"cover","content":"Point A\nPoint B","notes":"Speaker note"}]}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "assets", "assets_plan.json"), `{"assets":[]}`)
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
if err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func findSVGlideShortcutFlag(t *testing.T, name string) common.Flag {
|
||||
t.Helper()
|
||||
for _, flag := range SlidesCreateSVGlide.Flags {
|
||||
if flag.Name == name {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing flag %q", name)
|
||||
return common.Flag{}
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutDeck(t *testing.T, slidePath string) {
|
||||
t.Helper()
|
||||
deck := map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": []map[string]string{{
|
||||
"id": "cover",
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "cover",
|
||||
"key_message": "Message",
|
||||
"path": slidePath,
|
||||
}},
|
||||
}
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "outline", "deck.json"), string(raw))
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func svglideShortcutVisibleTextSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
var b bytes.Buffer
|
||||
_ = json.NewEncoder(&b).Encode(value)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
|
||||
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
||||
|
||||
@@ -1,23 +1,77 @@
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段。
|
||||
|
||||
## 路由优先级(先判断是不是审批,再选命令)
|
||||
|
||||
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
|
||||
|
||||
### 明确归 `lark-approval` 的高优先级语义
|
||||
|
||||
出现以下任一语义时,优先走 `lark-approval`:
|
||||
|
||||
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
|
||||
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
|
||||
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
|
||||
|
||||
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
| 想做什么 | 命令 | 按需读取 reference |
|
||||
|---|---|---------------------------------------------------------------------------------|
|
||||
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
|
||||
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
|
||||
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
|
||||
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
|
||||
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
|
||||
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
|
||||
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作
|
||||
|
||||
## 执行原则(减少误路由、误重试和无效消耗)
|
||||
|
||||
### 1) 先拿最小必要信息,再执行
|
||||
|
||||
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
|
||||
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
|
||||
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
|
||||
|
||||
### 2) 已知对象时直达动作
|
||||
|
||||
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
|
||||
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
|
||||
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
|
||||
|
||||
### 3) 错误码驱动,而不是盲目重试
|
||||
|
||||
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
|
||||
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
|
||||
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次
|
||||
|
||||
## 写操作失败处理:1395001 决策树
|
||||
|
||||
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
|
||||
|
||||
1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次
|
||||
2. 优先从以下角度解释:
|
||||
- 任务可能已被他人处理
|
||||
- 单据状态已变化,当前动作已不再允许
|
||||
- 当前用户已不具备该任务的操作资格
|
||||
- 当前节点或单据状态不支持该操作
|
||||
3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环
|
||||
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
|
||||
|
||||
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
@@ -27,14 +81,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
## 不在本 skill 范围
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
@@ -8,28 +8,83 @@ metadata:
|
||||
cliHelp: "lark-cli approval --help"
|
||||
---
|
||||
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段;**references 是第一信息源**,只有在 reference 未覆盖的原生 / 高级场景下,才额外用 `lark-cli ... --help`、`lark-cli schema` 等方式补充确认字段。
|
||||
|
||||
## 路由优先级(先判断是不是审批,再选命令)
|
||||
|
||||
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
|
||||
|
||||
### 明确归 `lark-approval` 的高优先级语义
|
||||
|
||||
出现以下任一语义时,优先走 `lark-approval`:
|
||||
|
||||
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
|
||||
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
|
||||
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
|
||||
|
||||
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
| 想做什么 | 命令 | 按需读取 reference |
|
||||
|---|---|---------------------------------------------------------------------------------|
|
||||
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
|
||||
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
|
||||
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
|
||||
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
|
||||
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
|
||||
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
|
||||
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作
|
||||
|
||||
## 执行原则(减少误路由、误重试和无效消耗)
|
||||
|
||||
### 1) 先拿最小必要信息,再执行
|
||||
|
||||
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
|
||||
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
|
||||
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
|
||||
|
||||
### 2) 已知对象时直达动作
|
||||
|
||||
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
|
||||
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
|
||||
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
|
||||
|
||||
### 3) 错误码驱动,而不是盲目重试
|
||||
|
||||
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
|
||||
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
|
||||
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次
|
||||
|
||||
## 写操作失败处理:1395001 决策树
|
||||
|
||||
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
|
||||
|
||||
1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次
|
||||
2. 优先从以下角度解释:
|
||||
- 任务可能已被他人处理
|
||||
- 单据状态已变化,当前动作已不再允许
|
||||
- 当前用户已不具备该任务的操作资格
|
||||
- 当前节点或单据状态不支持该操作
|
||||
3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环
|
||||
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
|
||||
|
||||
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
@@ -39,18 +94,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
128
skills/lark-approval/references/lark-approval-approvals-get.md
Normal file
128
skills/lark-approval/references/lark-approval-approvals-get.md
Normal file
@@ -0,0 +1,128 @@
|
||||
|
||||
# approval approvals get
|
||||
|
||||
获取单个审批定义详情(用户级只读操作)。适合在发起审批实例前,先确认审批名称、表单控件结构、选项值范围以及流程节点信息。
|
||||
|
||||
需要的 scopes: ["approval:approval:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按 approval_code 查询审批定义详情
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览顶层字段
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;通常来自 `approval approvals search` 的结果 |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批定义详情通常按当前用户可见范围读取 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 常见输入来源
|
||||
|
||||
如果你已经有 `approval_code`,可直接查询:
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
```
|
||||
|
||||
如果你还没有 `approval_code`,先搜索可发起审批定义:
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
```
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中,优先关注以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `approval_code` | 审批定义 Code |
|
||||
| `approval_name` | 审批定义名称;确认是不是用户想发起的那张单 |
|
||||
| `form` | 表单定义快照;用于识别控件 `id`、`type`、选项值范围、明细子控件结构 |
|
||||
| `node_list` | 流程节点列表;用于识别节点 key、是否需要补充审批人、是否允许多人 |
|
||||
|
||||
## form 的使用重点
|
||||
|
||||
`form` 最重要的作用是帮助 agent **识别怎么组装 `instances.create.data.form`**,而不是直接把它原样提交出去。
|
||||
|
||||
重点看:
|
||||
|
||||
| 字段 / 结构 | 说明 |
|
||||
|------|------|
|
||||
| `form[].id` | 控件 ID;后续创建实例时必须使用 |
|
||||
| `form[].type` | 控件类型,例如 `input`、`date`、`radio`、`checkbox`、`fieldList` |
|
||||
| `form[].value` / 选项定义 | 用来识别可选值范围、默认值或选项值 |
|
||||
| 明细 / 子控件结构 | 用于识别 `fieldList`、控件组等复杂控件的子字段结构 |
|
||||
|
||||
**注意:`approvals.get.form` 不是 `instances.create` 可直接复用的 payload 模板。** 它是“定义快照”,主要用于识别字段结构与选项值范围。
|
||||
|
||||
## node_list 的使用重点
|
||||
|
||||
`node_list` 主要用于后续决定是否要补 `node_approver_list` / `node_cc_list`。
|
||||
|
||||
重点看:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `node_list[].custom_node_id` | 自定义节点标识;后续补节点参数时优先作为 key |
|
||||
| `node_list[].node_id` | 节点 ID;若没有 `custom_node_id`,通常退回用它做 key |
|
||||
| `node_list[].need_approver` | 是否要求发起人补充审批人 |
|
||||
| `node_list[].approver_chosen_multi` | 是否允许为该节点选择多个审批人 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是发起原生审批实例前的必要只读步骤。** 推荐固定走:`approvals search` -> `approvals get` -> `instances create`。
|
||||
- **如果用户已经明确给了 `approval_code`,直接用这个命令。** 不必再走 `approvals search`。
|
||||
- **先确认 `approval_name`。** 避免把相似名称的审批定义搞混。
|
||||
- **先用 `form` 识别控件结构,再组装创建 payload。** 不要在未看详情时猜控件 `id`、`type` 或选项值。
|
||||
- **先用 `node_list` 看是否需要补审批人。** 若某节点 `need_approver=true`,创建实例时通常要补 `node_approver_list`。
|
||||
- **`node_list` 的 key 优先取 `custom_node_id`。** 若不存在,再使用 `node_id`。
|
||||
- **`approver_chosen_multi=false` 时,一个节点通常只能补一个审批人。**
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
读取定义详情后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 发起原生审批实例
|
||||
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
|
||||
```
|
||||
|
||||
如果需要进一步理解控件取值与节点参数,优先参考:
|
||||
|
||||
- `lark-approval-instance-form-control-parameters.md`
|
||||
- `lark-approval-instance-value-sourcing.md`
|
||||
- `lark-approval-initiate.md`
|
||||
|
||||
## 结果整理方式
|
||||
|
||||
**将结果整理为“审批定义概览 + 表单结构摘要 + 节点要求摘要”。**
|
||||
|
||||
建议输出成下面这种结构:
|
||||
|
||||
```text
|
||||
审批定义:请假申请
|
||||
approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
|
||||
|
||||
表单控件摘要:
|
||||
- leave_type: radio,可选值 [annual_leave, sick_leave]
|
||||
- reason: textarea
|
||||
- start_end: dateInterval
|
||||
|
||||
节点要求摘要:
|
||||
- manager_node:need_approver=true,approver_chosen_multi=false
|
||||
- hr_node:need_approver=false
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
# approval approvals search
|
||||
|
||||
搜索**当前用户可发起**的审批定义(launchable approvals)。只读操作,不会创建审批实例。
|
||||
|
||||
需要的 scopes: ["approval:approval:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词搜索可发起审批定义
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval approvals search --data '{"keyword":"请假", "page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览候选定义
|
||||
lark-cli approval approvals search --data '{"keyword":"出差"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `keyword` | 是 | 搜索关键词,例如 `请假`、`报销`、`出差`、`采购` |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;“可发起审批定义”是面向当前用户的查询 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 这个命令解决什么问题
|
||||
|
||||
当用户只有自然语言意图,还没有 `approval_code` 时,先用它把“可发起的审批定义候选项”找出来。
|
||||
|
||||
典型场景:
|
||||
|
||||
- “帮我找一下请假审批”
|
||||
- “有哪些可以发起的报销单?”
|
||||
- “先搜一下出差审批,再帮我提单”
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果里,优先关注以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `approval_code` | 审批定义 Code;后续 `approvals get` 和 `instances create` 都要用它 |
|
||||
| `approval_name` | 审批定义名称;给用户做候选选择时最关键 |
|
||||
| `is_external` | 是否为三方审批定义;`true` 表示不能走原生 `instances.create` |
|
||||
| `create_link` | 三方审批定义的发起链接;`is_external=true` 时优先返回给用户 |
|
||||
|
||||
## 使用规则
|
||||
|
||||
- **这是发起审批工作流的第一步。** 标准顺序是:`approvals search` -> `approvals get` -> `instances create`。
|
||||
- **搜索结果为空时,不要猜。** 直接告诉用户当前关键词下没有可发起定义,并建议用户换关键词。
|
||||
- **命中多个结果时,不要替用户拍板。** 先把候选定义列出来,让用户选择目标审批定义。
|
||||
- **`is_external=true` 时不要调用 `approval instances create`。** 这类定义属于三方审批,优先返回 `create_link` 并说明需要通过链接发起。
|
||||
- **只有 `is_external=false` 的原生定义,才继续 `approvals get`。**
|
||||
- **如果用户已经明确给出 `approval_code`,不要再 search。** 直接执行 `approval approvals get`。
|
||||
|
||||
## 结果整理方式
|
||||
|
||||
**将结果整理为候选清单,优先展示“名称 + approval_code + 是否三方定义 + 下一步建议”。**
|
||||
|
||||
建议输出成下面这种结构:
|
||||
|
||||
```text
|
||||
找到 3 个可发起审批定义:
|
||||
|
||||
1. 请假申请
|
||||
- approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
|
||||
- is_external: false
|
||||
- next: 可继续读取 definitions 详情(approvals get)
|
||||
|
||||
2. 差旅报销
|
||||
- approval_code: 99887766-xxxx
|
||||
- is_external: true
|
||||
- next: 返回 create_link,引导用户通过链接发起
|
||||
```
|
||||
|
||||
## 常见后续操作
|
||||
|
||||
### 1)用户选中了某个定义,继续查看详情
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
```
|
||||
|
||||
### 2)确认是原生定义后,再准备发起审批实例
|
||||
|
||||
```bash
|
||||
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
|
||||
```
|
||||
|
||||
### 3)确认是三方定义时,直接返回链接
|
||||
|
||||
当 `is_external=true` 时,优先向用户返回 `create_link`,说明该审批需在三方系统或跳转页面中发起,而不是通过原生 `instances.create`。
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
## 执行摘要
|
||||
|
||||
- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。
|
||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。
|
||||
- **原生审批提单如果用户未明确给出 `approval_code`,必须固定走 `approvals search` -> `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||
- **原生审批提单如果用户明确给出 `approval_code`,固定走 `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances create`,应优先使用 `create_link`。
|
||||
- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
|
||||
- **先读控件参数 reference 和值来源 reference,再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`。
|
||||
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
|
||||
- **节点参数只从 `node_list` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
|
||||
- **先读控件参数 reference 和值来源 reference,再读本文里的创建参数规则。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md)。
|
||||
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances create --data.form` 中,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
|
||||
- **节点参数只从 `node_list` 和本文里的节点参数规则里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,不要混用姓名或其他身份标识。
|
||||
- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`。
|
||||
- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`。
|
||||
- **创建实例前先确认。** `approval instances create` 是写操作,执行前,让用户确认最终定义、表单值和节点参数;真正执行时显式传 `--yes`。
|
||||
|
||||
## 适用场景
|
||||
|
||||
@@ -20,11 +21,10 @@
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。**
|
||||
- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。**
|
||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。
|
||||
- **严禁在未先阅读本文中的创建参数规则、[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 的情况下直接提单。**
|
||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances create`。
|
||||
- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。
|
||||
- **严禁对三方定义调用 `instances.create`。**
|
||||
- **严禁对三方定义调用 `instances create`。**
|
||||
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
|
||||
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
|
||||
- **严禁在未得到用户确认前直接执行真实提单。**
|
||||
@@ -33,10 +33,9 @@
|
||||
|
||||
### 1. 搜索可发起审批定义
|
||||
|
||||
先用 `schema` 看参数,再搜索定义:
|
||||
先搜索定义:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.search
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
```
|
||||
|
||||
@@ -44,7 +43,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
|
||||
- 若结果为空,告诉用户当前关键词下没有可发起定义。
|
||||
- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
|
||||
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`。
|
||||
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances create`。
|
||||
- 只有 `is_external=false` 的原生定义才继续下一步。
|
||||
|
||||
### 2. 获取审批定义详情
|
||||
@@ -52,7 +51,6 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
拿到 `approval_code` 后,读取定义详情:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.get
|
||||
lark-cli approval approvals get \
|
||||
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
|
||||
```
|
||||
@@ -63,12 +61,30 @@ lark-cli approval approvals get \
|
||||
- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
|
||||
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
|
||||
|
||||
### 3. 组装 `form`
|
||||
### 3. 创建请求参数速查
|
||||
|
||||
`instances.create.data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
输入参数如下:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 |
|
||||
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
|
||||
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
|
||||
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
|
||||
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
|
||||
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
### 4. 组装 `form`
|
||||
|
||||
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
|
||||
- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
|
||||
- `contact`、`department`、`fieldList`、`dateInterval`、`amount`、`telephone`、`document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。
|
||||
@@ -100,7 +116,7 @@ lark-cli approval approvals get \
|
||||
- `input` / `textarea`: `value` 是字符串
|
||||
- `date`: `value` 是 RFC3339 时间字符串
|
||||
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
|
||||
- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id`
|
||||
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 `option.value`;关联外部选项时传 `options.id`
|
||||
- `checkbox` / `checkboxV2`: `value` 是选项值数组
|
||||
- `number`: `value` 是数字
|
||||
- `amount`: `value` 是数字,还要带 `currency`
|
||||
@@ -129,7 +145,7 @@ lark-cli approval approvals get \
|
||||
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
|
||||
- 不要把控件组整体当成普通字符串或扁平对象提交
|
||||
|
||||
### 4. 组装节点参数
|
||||
### 5. 组装节点参数
|
||||
|
||||
从 `node_list` 推导节点参数:
|
||||
|
||||
@@ -139,13 +155,13 @@ lark-cli approval approvals get \
|
||||
- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。
|
||||
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
|
||||
|
||||
### 5. 创建审批实例
|
||||
### 6. 创建审批实例
|
||||
|
||||
先看 `schema`,确认最终结构后再执行:
|
||||
创建命令使用 `approval instances create`,需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
确认最终表单值和节点参数后再执行:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.instances.create
|
||||
|
||||
lark-cli approval instances create \
|
||||
--data '{
|
||||
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
|
||||
@@ -157,6 +173,8 @@ lark-cli approval instances create \
|
||||
}
|
||||
]
|
||||
}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -170,7 +188,7 @@ lark-cli approval instances create \
|
||||
|
||||
优先级固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。
|
||||
1. 本文中的创建请求参数、节点参数和返回结果说明:决定 `instances create` 要传哪些字段、怎么执行、成功后回什么。
|
||||
2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。
|
||||
3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
|
||||
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
|
||||
@@ -184,8 +202,8 @@ lark-cli approval instances create \
|
||||
|---|---|
|
||||
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
|
||||
| 已经拿到 `approval_code` | 直接 `approvals.get` |
|
||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
|
||||
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
|
||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances create` |
|
||||
| `is_external=true` | 返回 `create_link`,不要调 `instances create` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
@@ -194,3 +212,13 @@ lark-cli approval instances create \
|
||||
- `approval_name`
|
||||
- `instance_code`
|
||||
- `instance_link`
|
||||
|
||||
建议整理为下面这种结构:
|
||||
|
||||
```text
|
||||
审批已创建成功:
|
||||
|
||||
- approval_name: 请假申请
|
||||
- instance_code: 19EAC829-F1CB-527F-BE2A-1330422E60C0
|
||||
- instance_link: https://...
|
||||
```
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
阅读顺序固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create`
|
||||
1. [`lark-approval-initiate.md`](./lark-approval-initiate.md) 中的创建请求参数、节点参数和返回结果说明
|
||||
2. `approval approvals get` 返回的 `form` / `node_list`
|
||||
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
|
||||
4. 本文
|
||||
|
||||
## 总原则
|
||||
|
||||
- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
|
||||
- `lark-approval-initiate.md` 决定创建请求字段名、字段层级、节点参数结构。
|
||||
- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。
|
||||
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
|
||||
- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
|
||||
# approval instances cancel
|
||||
|
||||
撤回一个已发起的审批实例(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后再执行撤回。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要撤回该审批实例且目标实例无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval instances cancel \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 撤回一个审批实例
|
||||
lark-cli approval instances cancel \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体
|
||||
lark-cli approval instances cancel \
|
||||
--data @./cancel-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例撤回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
如果你要找“我发起的审批实例”,可先查询已发起列表:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
```
|
||||
|
||||
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instances[].instance_code` | 审批实例 Code;撤回时必须提供 |
|
||||
| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为撤回输入 |
|
||||
| `tasks[].instance_status` | 审批实例状态;可用于判断是否仍处于可撤回阶段 |
|
||||
|
||||
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **撤回的是审批实例,不是单个任务**:`instances cancel` 只需要 `instance_code`,不需要 `task_id`。
|
||||
- **优先确认实例是否仍可撤回**:已经通过、已拒绝、已撤销或已终止的实例通常不适合继续撤回。
|
||||
- **优先从 `instances initiated` 获取目标实例**:因为撤回通常针对“我发起的审批”,这个入口最直接。
|
||||
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个待办/已办上下文进入时,这样更方便。
|
||||
- **先 `--dry-run` 再执行**:尤其在实例来源不明确、用户只给了标题关键字,或一次要核对多个实例时,先预览更安全。
|
||||
105
skills/lark-approval/references/lark-approval-instances-cc.md
Normal file
105
skills/lark-approval/references/lark-approval-instances-cc.md
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
# approval instances cc
|
||||
|
||||
给一个审批实例追加抄送人(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后,再提供抄送人的用户 ID 执行抄送。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要抄送该审批实例且目标实例、抄送对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给项目 owner 了解进展"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 按 open_id 抄送一个人
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给你知悉"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 一次抄送多个人
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx","ou_yyy"],"comment":"请相关同学同步关注"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 按 user_id 抄送
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["123456789"],"comment":"抄送给财务负责人"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体
|
||||
lark-cli approval instances cc \
|
||||
--data @./cc-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 |
|
||||
| `cc_user_ids` | 是 | 抄送人的用户 ID 数组;需要和 `user_id_type` 保持一致 |
|
||||
| `comment` | 否 | 抄送留言,例如 `抄送给你知悉`、`请同步关注该审批进展` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `cc_user_ids` 内用户 ID 的类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认抄送人的 ID 类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例抄送通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
如果你要找“我发起的审批实例”,可先查询已发起列表:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
```
|
||||
|
||||
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instances[].instance_code` | 审批实例 Code;抄送时必须提供 |
|
||||
| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为抄送输入 |
|
||||
| `tasks[].title` | 任务标题,可用于确认是否是要操作的那个审批 |
|
||||
| `tasks[].instance_status` | 审批实例状态;可用于判断当前审批是否仍处于进行中 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行抄送。
|
||||
|
||||
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **抄送的是审批实例,不是单个任务**:`instances cc` 只需要 `instance_code`,不需要 `task_id`。
|
||||
- **`cc_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **`cc_user_ids` 是数组**:即使只抄送一个人,也要按数组形式传入。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **优先从 `instances initiated` 获取目标实例**:因为抄送常见于“我发起的审批”场景,这个入口最直接。
|
||||
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个审批上下文进入时,这样更方便。
|
||||
- **`comment` 建议简洁明确**:例如 `抄送给你知悉`、`请同步关注审批进展`。避免过长或模糊描述。
|
||||
- **先 `--dry-run` 再执行**:尤其在抄送对象较多、抄送人来源不明确,或需要让用户先核对实例标题时,先预览更安全。
|
||||
145
skills/lark-approval/references/lark-approval-instances-get.md
Normal file
145
skills/lark-approval/references/lark-approval-instances-get.md
Normal file
@@ -0,0 +1,145 @@
|
||||
|
||||
# approval instances get
|
||||
|
||||
获取单个审批实例详情(用户级只读操作)。适合在执行 approve / reject / transfer / rollback / cancel / cc / remind 之前,先查看审批表单、当前节点、任务列表、审批动态和整体状态。
|
||||
|
||||
需要的 scopes: ["approval:instance:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按实例 Code 查询详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览顶层字段
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例详情查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 常见输入来源
|
||||
|
||||
如果你已经有实例 Code,可直接查询:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
如果你还没有实例 Code,可先从以下命令获取:
|
||||
|
||||
```bash
|
||||
# 查询我发起的审批实例
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
|
||||
# 或从任务列表里拿到关联实例 Code
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instance_code` | 审批实例 Code |
|
||||
| `serial_number` | 审批单编号 |
|
||||
| `definition_code` | 审批定义 Code |
|
||||
| `definition_name` | 审批名称 |
|
||||
| `user_id` | 发起审批的用户 ID |
|
||||
| `department_id` | 发起人所在部门 ID |
|
||||
| `status` | 审批实例状态,见下方“status 枚举” |
|
||||
| `reverted` | 单据是否已被撤销 |
|
||||
| `start_time` | 审批创建时间 |
|
||||
| `end_time` | 审批完成时间,未完成时通常为 `0` |
|
||||
| `form` | 表单数据,JSON 字符串 |
|
||||
| `current_nodes` | 当前审批节点列表 |
|
||||
| `tasks` | 审批任务列表 |
|
||||
| `operation_records` | 审批动态,例如通过、拒绝、转交、加签、回退、撤回、抄送 |
|
||||
| `comments` | 评论列表 |
|
||||
|
||||
## status 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `PENDING` | 审批中 |
|
||||
| `APPROVED` | 已通过 |
|
||||
| `REJECTED` | 已拒绝 |
|
||||
| `CANCELED` | 已撤回 |
|
||||
| `DELETED` | 已删除 |
|
||||
|
||||
## current_nodes 重点字段
|
||||
|
||||
`current_nodes` 常用于判断审批流当前卡在哪一层:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------------------------------------------|
|
||||
| `current_nodes[].node_id` | 当前审批节点 ID |
|
||||
| `current_nodes[].node_name` | 当前审批节点名称 |
|
||||
| `current_nodes[].type` | 审批方式:`AND` 会签、`OR` 或签、`SEQUENTIAL` 依次审批等 |
|
||||
| `current_nodes[].approvers[].task_id` | 当前审批人关联任务 ID |
|
||||
| `current_nodes[].approvers[].user_id` | 当前审批人用户 ID |
|
||||
|
||||
## tasks 重点字段
|
||||
|
||||
`tasks` 常用于把实例和具体审批任务关联起来:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].id` | 审批任务 ID |
|
||||
| `tasks[].node_id` | 任务所属节点 ID |
|
||||
| `tasks[].node_name` | 任务所属节点名称 |
|
||||
| `tasks[].user_id` | 审批人用户 ID |
|
||||
| `tasks[].status` | 任务状态:`PENDING`、`APPROVED`、`REJECTED`、`TRANSFERRED`、`DONE` |
|
||||
| `tasks[].start_time` | 任务开始时间 |
|
||||
| `tasks[].end_time` | 任务完成时间 |
|
||||
|
||||
## operation_records 重点字段
|
||||
|
||||
`operation_records` 常用于审计审批过程:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `operation_records[].type` | 事件类型,如 `PASS`、`REJECT`、`TRANSFER`、`ROLLBACK`、`CANCEL`、`CC` |
|
||||
| `operation_records[].create_time` | 事件发生时间 |
|
||||
| `operation_records[].user_id` | 触发该事件的用户 ID |
|
||||
| `operation_records[].task_id` | 关联任务 ID |
|
||||
| `operation_records[].node_id` | 关联节点 ID |
|
||||
| `operation_records[].comment` | 理由 / 备注 |
|
||||
| `operation_records[].cc_user_ids` | 被抄送人列表(抄送事件时) |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是最适合做“详情确认”的只读命令**:当你已经拿到 `instance_code`,需要确认表单、当前节点、任务状态、审批动态时,优先使用它。
|
||||
- **在执行写操作前先看详情**:例如做 `tasks rollback` 前确认可退回节点,做 `instances cancel` 前确认实例状态,做 `tasks remind` 前确认当前任务是否仍待处理。
|
||||
- **`form` 是 JSON 字符串**:调用方通常还需要再解析一层,才能拿到表单字段值。
|
||||
- **`current_nodes` 和 `tasks` 可以联动看**:前者看“当前卡在哪个节点”,后者看“每个任务目前由谁处理、状态如何”。
|
||||
- **`operation_records` 适合做时间线回溯**:例如排查谁转交过、谁加签过、什么时候撤回或抄送过。
|
||||
- **优先显式传 `locale` 和 `user_id_type`**:这样 agent 更容易理解返回文本和 ID 语义,减少歧义。
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
读取详情后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 同意审批任务
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>"}' --as user --yes
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
|
||||
# 催办审批任务
|
||||
lark-cli approval tasks remind --data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"]}' --as user --yes
|
||||
```
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
# approval instances initiated
|
||||
|
||||
查询当前用户已发起的审批实例列表(用户级只读操作)。适合在需要查看“我发起了哪些审批”、筛选某类审批定义、获取 `instance_code` 供后续 `instances get` / `instances cancel` / `instances cc` 等命令使用时调用。
|
||||
|
||||
需要的 scopes: ["approval:instance:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询我发起的审批列表
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
|
||||
# 只看某个审批定义下我发起的实例
|
||||
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;已发起审批列表查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `count` | 列表计数,只在第一页返回;大于等于 100 个实例时返回 `99` |
|
||||
| `has_more` | 是否还有更多数据 |
|
||||
| `page_token` | 下一页翻页 Token |
|
||||
| `instances[].instance_code` | 审批实例 Code;后续查询详情或执行撤回 / 抄送时通常需要 |
|
||||
| `instances[].definition_code` | 审批定义 Code |
|
||||
| `instances[].definition_name` | 审批定义名称 |
|
||||
| `instances[].definition_group_id` | 审批定义分组 ID |
|
||||
| `instances[].definition_group_name` | 审批定义分组名称 |
|
||||
| `instances[].initiator` | 发起人 ID |
|
||||
| `instances[].initiator_name` | 发起人姓名 |
|
||||
| `instances[].instance_status` | 审批实例状态,见下方“instance_status 枚举” |
|
||||
| `instances[].instance_external_id` | 第三方审批实例 ID(仅第三方审批实例存在) |
|
||||
| `instances[].link` | 三方审批跳转链接 |
|
||||
| `instances[].summaries` | 摘要字段列表 |
|
||||
|
||||
## instance_status 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `0` | 无流程状态,不展示对应标签 |
|
||||
| `1` | 流程实例流转中 |
|
||||
| `2` | 已通过 |
|
||||
| `3` | 已拒绝 |
|
||||
| `4` | 已撤销 |
|
||||
| `5` | 已终止 |
|
||||
|
||||
## 常见使用场景
|
||||
|
||||
### 1) 找到我要操作的审批实例
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
|
||||
```
|
||||
|
||||
拿到 `instances[].instance_code` 后,可继续:
|
||||
|
||||
```bash
|
||||
# 查看审批实例详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
```
|
||||
|
||||
### 2) 只看某类审批
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated \
|
||||
--params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' \
|
||||
--as user
|
||||
```
|
||||
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。
|
||||
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
|
||||
- **结果很多时优先 `--format table`**:适合人工快速浏览。
|
||||
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
|
||||
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
|
||||
- **摘要字段 `summaries` 很适合做列表预览**:当审批标题不够明确时,可结合摘要值帮助识别目标实例。
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
拿到列表后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 查看单个审批实例详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
|
||||
# 给审批实例追加抄送人
|
||||
lark-cli approval instances cc --data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["<USER_ID>"]}' --params '{"user_id_type":"open_id"}' --as user --yes
|
||||
```
|
||||
120
skills/lark-approval/references/lark-approval-tasks-add-sign.md
Normal file
120
skills/lark-approval/references/lark-approval-tasks-add-sign.md
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
# approval tasks add_sign
|
||||
|
||||
给一个审批任务加签(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被加签人的用户 ID、加签方式等参数执行加签。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要对该审批任务加签且目标任务、加签对象、加签方式都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"前加签给财务复核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 前加签(需要 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"请先补充审核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 后加签(需要 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":2,"add_sign_user_ids":["ou_xxx","ou_yyy"],"approval_method":2,"comment":"当前审批完成后请两位继续审核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 并加签(常见场景可不传 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":3,"add_sign_user_ids":["123456789"],"comment":"并加签给项目 owner"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或较多加签人
|
||||
lark-cli approval tasks add_sign \
|
||||
--data @./add-sign-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `add_sign_type` | 是 | 加签类型:`1` 前加签、`2` 后加签、`3` 并加签 |
|
||||
| `add_sign_user_ids` | 是 | 被加签人 ID 数组;需要和 `user_id_type` 保持一致 |
|
||||
| `approval_method` | 否 | 审批方式:`1` 或签、`2` 会签、`3` 依次审批;**仅在前加签、后加签时需要填写** |
|
||||
| `comment` | 否 | 审批意见或加签说明,例如 `前加签给财务复核`、`请项目 owner 一并确认` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `add_sign_user_ids` 内用户 ID 的类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认被加签人的 ID 类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批加签通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 枚举说明
|
||||
|
||||
### add_sign_type
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `1` | 前加签 |
|
||||
| `2` | 后加签 |
|
||||
| `3` | 并加签 |
|
||||
|
||||
### approval_method
|
||||
|
||||
| 值 | 含义 | 适用场景 |
|
||||
|----|------|----------|
|
||||
| `1` | 或签 | 前加签 / 后加签 |
|
||||
| `2` | 会签 | 前加签 / 后加签 |
|
||||
| `3` | 依次审批 | 前加签 / 后加签 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback / add_sign 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;加签前建议先检查 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行加签。
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行加签操作。
|
||||
- **`add_sign_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **`add_sign_type` 要和业务意图一致**:前加签是在当前审批前插入审批人,后加签是在当前审批后追加审批人,并加签则是增加并行审批人。
|
||||
- **前加签 / 后加签要补 `approval_method`**:不要遗漏,否则请求可能无法准确表达审批方式。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 add_sign 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,加签前应谨慎验证。
|
||||
- **`comment` 建议写明加签原因**:例如 `增加财务复核`、`增加项目 owner 并行确认`,方便相关人员理解上下文。
|
||||
- **先 `--dry-run` 再执行**:尤其在多人加签、跨部门加签或加签对象来源不明确时,先预览更安全。
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
# approval tasks approve
|
||||
|
||||
同意一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行同意。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确同意审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 同意审批任务,并附带审批意见
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 需要回填表单时,传入 form(按当前命令定义,form 为字符串化 JSON)
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意并补充信息","form":"[{\"id\":\"user_name\",\"type\":\"input\",\"value\":\"Alice\"}]"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment / form
|
||||
lark-cli approval tasks approve \
|
||||
--data @./approve-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `comment` | 否 | 审批意见,例如 `同意`、`已确认` |
|
||||
| `form` | 否 | 表单数据;按当前命令定义,字段类型为 `string`,通常传字符串化 JSON;仅在审批动作需要同时回填表单时使用 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批同意通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的两个字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行同意操作。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 approve 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。
|
||||
- **`comment` 建议简洁明确**:例如 `同意`、`同意,信息已核对`。没有审批意见要求时可省略。
|
||||
- **`form` 只在确有需要时传**:大多数简单同意场景只传 `instance_code`、`task_id`、可选 `comment` 即可。
|
||||
- **先 `--dry-run` 再执行**:尤其在批量处理、表单回填或任务来源不明确时,先预览更安全。
|
||||
76
skills/lark-approval/references/lark-approval-tasks-query.md
Normal file
76
skills/lark-approval/references/lark-approval-tasks-query.md
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
# approval tasks query
|
||||
|
||||
查询当前用户的审批任务列表,可用于查看待办、已办、知会等分组。只读操作,不会修改审批状态。
|
||||
|
||||
需要的 scopes: ["approval:task:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询待办审批
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
|
||||
# 查询已办审批
|
||||
lark-cli approval tasks query --params '{"topic":"2"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `topic` | 是 | 任务分组主题,见下方“topic 枚举” |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批任务查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## topic 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `1` | 待办审批 |
|
||||
| `2` | 已办审批 |
|
||||
| `17` | 未读知会 |
|
||||
| `18` | 已读知会 |
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `count` | 列表计数,只在第一页返回;当任务数大于等于 100 时返回 `99` |
|
||||
| `has_more` | 是否还有更多数据 |
|
||||
| `page_token` | 下一页翻页 Token |
|
||||
| `tasks[].task_id` | 任务 ID,全局唯一 |
|
||||
| `tasks[].instance_code` | 审批实例 Code;后续执行 approve / reject / rollback 等操作时通常需要与 `task_id` 成对使用 |
|
||||
| `tasks[].title` | 任务标题 |
|
||||
| `tasks[].status` | 任务状态:`1` 待办、`2` 已办、`17` 未读、`18` 已读、`33` 处理中、`34` 撤回 |
|
||||
| `tasks[].topic` | 任务所属分组主题 |
|
||||
| `tasks[].instance_status` | 审批实例状态:`0` 无状态、`1` 流转中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 已终止 |
|
||||
| `tasks[].definition_code` | 审批定义 Code |
|
||||
| `tasks[].definition_name` | 审批定义名称 |
|
||||
| `tasks[].initiator` | 发起人 ID |
|
||||
| `tasks[].initiator_name` | 发起人姓名 |
|
||||
| `tasks[].summaries` | 表单摘要字段列表 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
|
||||
| `tasks[].user_id` | 任务所属用户 ID |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。
|
||||
- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。
|
||||
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。
|
||||
- 当结果量较大时,优先使用 `--format table` 提升可读性。
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
# approval tasks reject
|
||||
|
||||
拒绝一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行拒绝。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要拒绝该审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks reject \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 拒绝审批任务,并附带审批意见
|
||||
lark-cli approval tasks reject \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝,信息不完整"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment
|
||||
lark-cli approval tasks reject \
|
||||
--data @./reject-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `comment` | 否 | 审批意见,例如 `拒绝`、`拒绝,信息不完整` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批拒绝通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的两个字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行拒绝操作。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 reject 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。
|
||||
- **`comment` 建议写清拒绝原因**:例如 `拒绝,缺少合同附件`、`拒绝,预算字段填写不完整`。这有助于发起人理解原因并补充材料。
|
||||
- **先 `--dry-run` 再执行**:尤其在批量处理或任务来源不明确时,先预览更安全。
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
# approval tasks remind
|
||||
|
||||
对审批实例中的指定任务发起催办(用户级写操作)。通常先通过 `tasks query` 找到待办任务,拿到 `instance_code` 和要催办的 `task_ids`,必要时再用 `instances get` 查看详情,然后执行催办。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要催办该审批且目标实例、目标任务都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快处理"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 催办单个审批任务
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快审批该单据"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 同一实例下催办多个任务
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID_1>","<TASK_ID_2>"],"comment":"请相关审批人尽快处理"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或多个 task_ids
|
||||
lark-cli approval tasks remind \
|
||||
--data @./remind-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances get` 获取 |
|
||||
| `task_ids` | 是 | 被催办的任务 ID 数组;应与 `instance_code` 属于同一审批实例 |
|
||||
| `comment` | 否 | 催办说明,例如 `请尽快处理`、`该单据较急,请优先审批` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批催办通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;催办时必须提供 |
|
||||
| `tasks[].task_id` | 审批任务 ID;放入 `task_ids` 数组中 |
|
||||
| `tasks[].title` | 任务标题,可用于确认催办对象是否正确 |
|
||||
| `tasks[].status` | 任务状态;一般优先催办仍处于待处理状态的任务 |
|
||||
|
||||
如需进一步确认当前审批流、节点和人员信息,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_ids` 要对应同一个审批实例**:不要把不同实例下的任务 ID 混在同一次催办请求中。
|
||||
- **`task_ids` 是数组**:即使只催办一个任务,也要按数组形式传入。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 remind 的输入来源。
|
||||
- **催办前先确认任务仍需处理**:已经审批完成、已撤回或已终止的任务一般不适合继续催办。
|
||||
- **`comment` 建议简洁且明确**:例如 `该单据较急,请优先审批`、`请今天内处理`。避免过长或模糊描述。
|
||||
- **先 `--dry-run` 再执行**:尤其在一次催办多个任务、任务来源不明确或需让用户复核催办对象时,先预览更安全。
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
# approval tasks rollback
|
||||
|
||||
将一个审批任务退回到指定节点(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,再结合实例详情确认可退回的目标节点 `node_ids`,最后执行退回。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要退回该审批且目标任务、退回节点都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"退回补充材料"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 退回到单个节点
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"请补充附件后重新提交"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 传多个候选节点 ID(以实际审批定义支持情况为准)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或较多 node_ids
|
||||
lark-cli approval tasks rollback \
|
||||
--data @./rollback-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;退回前建议先检查 |
|
||||
|
||||
如需确认流程节点、当前进度和可退回位置,可先查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
|
||||
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
|
||||
- **`comment` 建议写清退回原因**:例如 `附件缺失,请补齐后重新提交`、`费用说明不完整,请补充明细`,方便发起人或上一步处理人理解原因。
|
||||
- **先 `--dry-run` 再执行**:尤其在节点来源不明确、审批链路复杂或批量处理时,先预览更安全。
|
||||
@@ -0,0 +1,91 @@
|
||||
|
||||
# approval tasks transfer
|
||||
|
||||
转交一个审批任务给其他用户处理(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被转交人的用户 ID 执行转交。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要转交该审批且目标任务、转交对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"请你继续处理"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 按 open_id 转交审批任务
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"转交给你处理"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 按 user_id 转交审批任务
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"123456789","comment":"请补充审核"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment
|
||||
lark-cli approval tasks transfer \
|
||||
--data @./transfer-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `transfer_user_id` | 是 | 被转交人的用户 ID;需要和 `user_id_type` 保持一致 |
|
||||
| `comment` | 否 | 审批意见或转交说明,例如 `转交给你处理`、`请继续审核该单据` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `transfer_user_id` 的 ID 类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认 `transfer_user_id` 的真实类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批转交通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;转交前建议先检查 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行转交。
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行转交操作。
|
||||
- **`transfer_user_id` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 transfer 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行同意/拒绝等处理动作,转交前也应谨慎验证。
|
||||
- **`comment` 建议写明转交原因**:例如 `你更熟悉该项目,请继续处理`、`转交给预算 owner 审核`,方便接收人理解上下文。
|
||||
- **先 `--dry-run` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。
|
||||
@@ -60,6 +60,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
| ------------------- | -- |---------------------------------------------|
|
||||
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
|
||||
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
|
||||
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `--command` | 是 | 操作指令(见下方指令速查表) |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--pattern` | 视指令 | 匹配文本(str_replace) |
|
||||
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
|
||||
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
|
||||
|
||||
@@ -24,8 +24,8 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
脚本输出 JSON。对用户汇报时默认只读两个核心字段:
|
||||
|
||||
- `word_count`:总字数。按语义单位统计汉字、英文单词、数字、中文标点;英文标点不计入。
|
||||
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点;空格不计入。
|
||||
- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点;普通贴着英文的英文标点不计入,但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准。
|
||||
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
|
||||
|
||||
其余字段用于排查或解释:
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ ENGLISH_PUNCTUATION = set(
|
||||
|
||||
|
||||
LexemeKind = Literal["english", "number"]
|
||||
URL_TOKEN_RE = re.compile(r"https?://[!-~]+")
|
||||
ASCII_COMPOUND_TOKEN_RE = re.compile(
|
||||
r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -181,8 +185,17 @@ class Counter:
|
||||
return self.stats
|
||||
|
||||
def write(self, text: str) -> None:
|
||||
for ch in text:
|
||||
self._write_char(ch)
|
||||
i = 0
|
||||
while i < len(text):
|
||||
consumed = self._write_ascii_compound_token(text, i)
|
||||
if consumed:
|
||||
i += consumed
|
||||
continue
|
||||
if self._write_visible_ascii_separator(text, i):
|
||||
i += 1
|
||||
continue
|
||||
self._write_char(text[i])
|
||||
i += 1
|
||||
|
||||
def write_marker(self, text: str) -> None:
|
||||
for ch in text:
|
||||
@@ -336,6 +349,65 @@ class Counter:
|
||||
self._end_symbol_run(count_word=False)
|
||||
self._at_boundary = False
|
||||
|
||||
def _write_visible_ascii_separator(self, text: str, index: int) -> bool:
|
||||
ch = text[index]
|
||||
if ch != "/" or index == 0 or index + 1 >= len(text):
|
||||
return False
|
||||
if not is_han(text[index - 1]) or not is_han(text[index + 1]):
|
||||
return False
|
||||
|
||||
self._end_unit()
|
||||
self.stats.english_punctuations += 1
|
||||
self.stats.symbol_words += 1
|
||||
self.stats.word_count += 1
|
||||
self.stats.char_count += 1
|
||||
self._at_boundary = False
|
||||
return True
|
||||
|
||||
def _write_ascii_compound_token(self, text: str, start: int) -> int:
|
||||
token = self._match_ascii_compound_token(text, start)
|
||||
if not token:
|
||||
return 0
|
||||
|
||||
self._end_unit()
|
||||
self.stats.english_words += 1
|
||||
self.stats.word_count += 1
|
||||
for ch in token:
|
||||
if is_ascii_letter(ch):
|
||||
self.stats.english_letters += 1
|
||||
self.stats.char_count += 1
|
||||
elif is_digit(ch):
|
||||
self.stats.digits += 1
|
||||
self.stats.char_count += 1
|
||||
elif is_english_punctuation(ch):
|
||||
self.stats.english_punctuations += 1
|
||||
self.stats.char_count += 1
|
||||
elif is_unicode_symbol(ch):
|
||||
units = utf16_units(ch)
|
||||
self.stats.symbol_chars += units
|
||||
self.stats.char_count += units
|
||||
elif is_chinese_punctuation(ch):
|
||||
self.stats.chinese_punctuations += 1
|
||||
self.stats.char_count += 1
|
||||
elif is_han(ch):
|
||||
self.stats.han_chars += 1
|
||||
self.stats.char_count += 1
|
||||
self._at_boundary = False
|
||||
return len(token)
|
||||
|
||||
def _match_ascii_compound_token(self, text: str, start: int) -> str | None:
|
||||
match = URL_TOKEN_RE.match(text, start)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
match = ASCII_COMPOUND_TOKEN_RE.match(text, start)
|
||||
if not match:
|
||||
return None
|
||||
token = match.group(0)
|
||||
if any(is_ascii_letter(ch) for ch in token):
|
||||
return token
|
||||
return None
|
||||
|
||||
def _write_symbol_char(self, ch: str) -> None:
|
||||
self._end_lexeme()
|
||||
self._end_symbol_run(count_word=False)
|
||||
@@ -361,7 +433,7 @@ class Counter:
|
||||
self._lexeme_has_digit = False
|
||||
|
||||
def _end_symbol_run(self, *, count_word: bool) -> None:
|
||||
if self._symbol_run_length >= 2 and count_word:
|
||||
if self._symbol_run_length >= 1 and count_word:
|
||||
self.stats.symbol_words += 1
|
||||
self.stats.word_count += 1
|
||||
if self._symbol_run_length:
|
||||
|
||||
@@ -59,6 +59,8 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m
|
||||
|
||||
### Card Messages (Interactive)
|
||||
|
||||
**Before sending or replying with any `interactive` card (`+messages-send` / `+messages-reply`), you MUST read [`references/card/lark-im-card-create.md`](references/card/lark-im-card-create.md) and follow its workflow.** The card JSON passed to `--msg-type interactive --content` must be the output of that workflow — never hand-write or copy a card payload.
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
`interactive` cards support callback events (`card.action.trigger`) — see [`references/lark-im-card-action-reply.md`](references/lark-im-card-action-reply.md).
|
||||
@@ -102,6 +104,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
|
||||
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
@@ -139,10 +142,8 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
|
||||
### chat.members
|
||||
|
||||
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
|
||||
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
|
||||
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
|
||||
### chat.user_setting
|
||||
|
||||
@@ -213,10 +214,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `chats.get` | `im:chat:read` |
|
||||
| `chats.link` | `im:chat:read` |
|
||||
| `chats.update` | `im:chat:update` |
|
||||
| `chat.members.bots` | `im:chat.members:read` |
|
||||
| `chat.members.create` | `im:chat.members:write_only` |
|
||||
| `chat.members.delete` | `im:chat.members:write_only` |
|
||||
| `chat.members.get` | `im:chat.members:read` |
|
||||
| `+chat-members-list` | `im:chat.members:read` |
|
||||
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
|
||||
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
|
||||
| `chat.managers.add_managers` | `im:chat.managers:write_only` |
|
||||
|
||||
107
skills/lark-im/references/card/card-2.0-schema.md
Normal file
107
skills/lark-im/references/card/card-2.0-schema.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 卡片 2.0 组件大纲
|
||||
|
||||
Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声明。先在下表按用途选组件,再点明细看字段:有明细文件的点 `components/<tag>.md`(完整字段+示例+易错点),低频组件点链接看官方文档。
|
||||
|
||||
## 根结构
|
||||
|
||||
顶层固定四字段,先搭骨架再往 `body.elements` 填组件。以下为**推荐完整骨架**(含 type scale、light/dark color token、header 三件套):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "2.0",
|
||||
"config": {
|
||||
"update_multi": true,
|
||||
"width_mode": "default",
|
||||
"style": {
|
||||
"text_size": {
|
||||
"title": { "default": "heading-2", "pc": "heading-2", "mobile": "heading-3" },
|
||||
"body": { "default": "normal", "pc": "normal", "mobile": "normal" },
|
||||
"caption": { "default": "notation", "pc": "notation", "mobile": "notation" }
|
||||
},
|
||||
"color": {
|
||||
"cus-primary": { "light_mode": "rgba(30,120,255,1)", "dark_mode": "rgba(80,150,255,1)" },
|
||||
"cus-primary-bg": { "light_mode": "rgba(30,120,255,0.08)", "dark_mode": "rgba(80,150,255,0.12)" },
|
||||
"cus-muted": { "light_mode": "rgba(100,106,115,1)", "dark_mode": "rgba(150,155,163,1)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
|
||||
"text_tag_list": [
|
||||
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
|
||||
]
|
||||
},
|
||||
"body": { "direction": "vertical", "padding": "12px 12px 20px 12px", "elements": [] }
|
||||
}
|
||||
```
|
||||
|
||||
> **按需裁剪**:`subtitle` / `text_tag_list` / color token 按实际诉求取舍,不强制全用。组件里用 `"text_size": "title"` / `"caption"` 引用 token,用 `"font_color": "cus-muted"` 引用颜色 token;主色系变化时只需改 config 里的 RGBA,全卡自动跟随。
|
||||
|
||||
- `schema` 必须显式为 `"2.0"`,否则按 1.0 渲染。`header` 详见 `components/header.md`。
|
||||
- **元素通用字段**(所有 `elements[]` 组件):`tag`(必填) · `element_id`(卡内唯一,字母开头、≤20 字符) · `margin`(外边距 [-99,99]px)。
|
||||
- `card_link`(整卡跳转):`{url, pc_url, ios_url, android_url}`,至少填 `url`;某端禁跳设 `lark://msgcard/unsupported_action`。
|
||||
- 硬限制:单卡 ≤ **200** 元素;需客户端 **≥ 7.20**(旧版仅显示 header)。
|
||||
- 颜色 / 图标枚举见 `resource/colors.md` · `resource/icons.md`。
|
||||
|
||||
**config**(全局行为,可整体省略):
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `update_multi` | true | 共享卡片,v2 仅支持 true |
|
||||
| `width_mode` | default | `default`(≤600px) / `compact`(400px) / `fill`(撑满) |
|
||||
| `enable_forward` | true | 是否允许转发 |
|
||||
| `summary` | — | 会话列表预览:`{content, i18n_content:{zh_cn,en_us,…}}` |
|
||||
| `streaming_mode` | false | 流式更新模式(配 `streaming_config`) |
|
||||
| `style.text_size` | — | 自定义字号 token,格式 `{"<名称>":{default,pc,mobile}}`;名称可自定义(如 `title`/`caption`),组件 `text_size` 引用该名称 |
|
||||
| `style.color` | — | 自定义颜色 token,格式 `{"<名称>":{light_mode,dark_mode}}`(RGBA);名称可自定义(如 `cus-primary`),组件 `font_color`/`background_style` 等字段引用 |
|
||||
|
||||
> 多语言:`config.locales` 限定生效语种、`use_custom_translation` 优先用自带 i18n。
|
||||
|
||||
**body 布局字段**(均 v2 新增):`direction`(vertical/horizontal) · `padding`([0,99]px) · `horizontal_spacing`/`vertical_spacing`(`small`4/`medium`8/`large`12/`extra_large`16 或 px) · `horizontal_align`/`vertical_align`。
|
||||
|
||||
---
|
||||
|
||||
## 容器类(布局 / 组织交互)
|
||||
|
||||
| 组件 | 用途 |
|
||||
|---|---|
|
||||
| [column_set](components/column_set.md) | 横向分栏,多列图文对齐(数据表、字段对、列表) |
|
||||
| [collapsible_panel](components/collapsible_panel.md) | 折叠面板,收纳备注/长文本等次要信息 |
|
||||
| [form](components/form.md) | 表单容器,批量录入表单项后一次提交 |
|
||||
| [interactive_container](components/interactive_container.md) | 整块可点击区域,可统一定义样式与交互 |
|
||||
| [循环容器](components/recycling_container.md) | 批量渲染同版式不同数据(仅搭建工具) |
|
||||
|
||||
## 展示类(无交互)
|
||||
|
||||
| 组件 | 用途 |
|
||||
|---|---|
|
||||
| [header](components/header.md) | 卡片标题区:主/副标题、后缀标签、主题色 |
|
||||
| [div](components/div.md) | 普通文本,带前缀图标、字段对 |
|
||||
| [markdown](components/markdown.md) | 富文本,最常用;@人、彩色、链接、列表、表格等 |
|
||||
| [img](components/img.md) | 单图 |
|
||||
| [img_combination](components/img_combination.md) | 多图拼排(双图/三图/宫格) |
|
||||
| [person](components/person.md) | 单个人员头像/姓名 |
|
||||
| [person_list](components/person_list.md) | 多个人员头像/姓名 |
|
||||
| [chart](components/chart.md) | VChart 图表(折线/柱/饼/词云等) |
|
||||
| [table](components/table.md) | 多列数据表(只能放根节点) |
|
||||
| [hr](components/hr.md) | 分割线 |
|
||||
|
||||
## 交互类
|
||||
|
||||
| 组件 | 用途 |
|
||||
|---|---|
|
||||
| [button](components/button.md) | 按钮:回调 / 跳转 / 表单提交 |
|
||||
| [input](components/input.md) | 文本输入框(多嵌在 form 内) |
|
||||
| [overflow](components/overflow.md) | 折叠按钮组,收纳多个操作 |
|
||||
| [select_static](components/select_static.md) | 下拉单选 |
|
||||
| [multi_select_static](components/multi_select_static.md) | 下拉多选 |
|
||||
| [select_person](components/select_person.md) | 人员单选 |
|
||||
| [multi_select_person](components/multi_select_person.md) | 人员多选 |
|
||||
| [date_picker](components/date_picker.md) | 日期选择器 |
|
||||
| [picker_time](components/picker_time.md) | 时间选择器 |
|
||||
| [picker_datetime](components/picker_datetime.md) | 日期时间选择器 |
|
||||
| [select_img](components/select_img.md) | 图片选择(单/多选) |
|
||||
| [checker](components/checker.md) | 勾选器,任务勾选回调 |
|
||||
63
skills/lark-im/references/card/components/button.md
Normal file
63
skills/lark-im/references/card/components/button.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 按钮 `button`
|
||||
|
||||
交互按钮,支持跳转 / 回调 / 表单提交三类行为。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "button",
|
||||
"text": { "tag": "plain_text", "content": "确定" },
|
||||
"type": "primary",
|
||||
"behaviors": [{ "type": "callback", "value": { "action": "ok" } }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `button` |
|
||||
| `text` | 否 | Object | / | `{tag:"plain_text", content}`,≤100 字符 |
|
||||
| `type` | 否 | String | default | 见下方 type 枚举 |
|
||||
| `size` | 否 | String | medium | `tiny` / `small` / `medium` / `large` |
|
||||
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
|
||||
| `behaviors` | 是* | Array | / | 交互行为,见下;表单内按钮不用 behaviors 而用 `form_action_type` |
|
||||
| `icon` | 否 | Object | / | 前缀图标(同 `div.icon`) |
|
||||
| `hover_tips` | 否 | Object | / | PC 端悬浮提示,plain_text |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用 |
|
||||
| `disabled_tips` | 否 | Object | / | 禁用后悬浮提示,plain_text |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_text,title 必填) |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
**type 枚举**:`default`(黑字描边) / `primary`(蓝字描边) / `danger`(红字描边) / `text` / `primary_text` / `danger_text`(无边框) / `primary_filled`(蓝底白字) / `danger_filled`(红底白字) / `laser`(镭射)。
|
||||
|
||||
## 按钮主次(强制)
|
||||
|
||||
- 全卡仅 1 个按钮 → `type: "primary_filled"`,并 `width: "fill"` 撑满成强焦点。
|
||||
- 多个并列按钮 → 第一个(主操作)`primary_filled`,其余一律 `default`,形成「一主多次」层级。
|
||||
- 删除 / 拒绝等危险操作用 `danger` 系(`danger` 或 `danger_filled`)。
|
||||
|
||||
## behaviors(交互行为)
|
||||
|
||||
```json
|
||||
// 1. 服务端回调
|
||||
{ "type": "callback", "value": { "key": "v" } }
|
||||
// 2. 跳转链接(可与 callback 同数组共存)
|
||||
{ "type": "open_url", "default_url": "https://x", "pc_url": "", "ios_url": "", "android_url": "" }
|
||||
```
|
||||
|
||||
表单容器内的按钮 **不用 behaviors**,改用根字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `name` | 是 | 表单内唯一标识 |
|
||||
| `form_action_type` | 是 | `submit`(提交表单)/ `reset`(重置) |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
|
||||
- 2.0 已废弃 `action` 交互模块,按钮直接放 `elements`,用间距控制排列。
|
||||
- 旧式 `url`/`value` 顶层字段是 1.0 写法;2.0 一律用 `behaviors`。
|
||||
- 点击触发 `card.action.trigger`,回传 `action.tag="button"` + `action.value`(即 callback 的 value)。
|
||||
57
skills/lark-im/references/card/components/chart.md
Normal file
57
skills/lark-im/references/card/components/chart.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 图表 `chart`
|
||||
|
||||
基于 VChart 的可视化图表(折线/柱/饼/词云等)。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "chart",
|
||||
"chart_spec": {
|
||||
"type": "line",
|
||||
"title": { "text": "趋势" },
|
||||
"data": { "values": [
|
||||
{ "time": "周一", "value": 8 },
|
||||
{ "time": "周二", "value": 14 }
|
||||
] },
|
||||
"xField": "time",
|
||||
"yField": "value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `chart` |
|
||||
| `chart_spec` | 是 | Object | / | VChart 图表定义,见下 |
|
||||
| `aspect_ratio` | 否 | String | 16:9(PC)/1:1(移动) | `1:1` / `2:1` / `4:3` / `16:9` |
|
||||
| `color_theme` | 否 | String | brand | `brand` / `rainbow` / `complementary` / `converse` / `primary`;chart_spec 里声明了样式则此项无效 |
|
||||
| `height` | 否 | String | auto | `auto`(按宽高比) 或 `[1,999]px`(设固定高则 aspect_ratio 失效) |
|
||||
| `preview` | 否 | Boolean | true | 是否可独立窗口/全屏查看 |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
## chart_spec 常用类型
|
||||
|
||||
`chart_spec` 是标准 VChart spec。核心字段:`type`、`data.values`(数据数组)、`xField`/`yField`(轴字段)、`seriesField`(分组)、`title.text`、`legends`。
|
||||
|
||||
| 图表 | type | 关键字段 |
|
||||
|---|---|---|
|
||||
| 折线 | `line` | `xField`, `yField` |
|
||||
| 面积 | `area` | `xField`, `yField` |
|
||||
| 柱状 | `bar` | `xField`, `yField`,分组加 `seriesField` |
|
||||
| 条形(横向) | `bar` | `direction:"horizontal"`,`xField`=值,`yField`=类别 |
|
||||
| 饼/环 | `pie` | `valueField`, `categoryField`,环图加 `innerRadius` |
|
||||
| 散点 | `scatter` | `xField`, `yField` |
|
||||
| 词云 | `wordCloud` | `nameField`, `valueField` |
|
||||
|
||||
完整属性参考 [VChart 官方文档](https://www.visactor.io/vchart/option/barChart)。
|
||||
|
||||
## 易错点
|
||||
|
||||
- 不支持 JavaScript 语法,`chart_spec` 必须是纯 JSON。
|
||||
- 单卡建议 ≤5 个图表。
|
||||
- 移动端不支持部分 VChart 属性(纹理 texture、conical 渐变、grid 词云布局等),用了会在移动端加载失败。
|
||||
- 平台默认给 chart_spec 追加 media query 自适应;要自控可设 `"media": []`。
|
||||
38
skills/lark-im/references/card/components/checker.md
Normal file
38
skills/lark-im/references/card/components/checker.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 勾选器 `checker`
|
||||
|
||||
任务勾选场景的交互组件,支持配置回调响应。仅支持手写 JSON,搭建工具不支持构建。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "checker",
|
||||
"name": "check_1",
|
||||
"checked": false,
|
||||
"text": { "tag": "plain_text", "content": "完成新品上市计划报告" },
|
||||
"behaviors": [{ "type": "callback", "value": { "key": "todo1" } }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `checker` |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `checked` | 否 | Boolean | false | 初始勾选状态 |
|
||||
| `text` | 否 | Object | / | `{tag:"plain_text"\|"lark_md", content, text_size?, text_color?, text_align?}`(text_color 见 `../resource/colors.md`) |
|
||||
| `overall_checkable` | 否 | Boolean | true | 悬浮时整体是否有阴影效果 |
|
||||
| `button_area` | 否 | Object | / | `{pc_display_rule:"always"|"on_hover", buttons:[<=3 个 button]}` |
|
||||
| `checked_style` | 否 | Object | / | `{show_strikethrough, opacity}`,勾选后的内容样式 |
|
||||
| `disabled` / `disabled_tips` | 否 | Boolean/Object | false / 空 | 禁用及禁用提示 |
|
||||
| `hover_tips` | 否 | Object | 空 | 悬浮提示;与 `disabled_tips` 同配时后者生效 |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]`;**未配置时仅本地勾选生效,不触发回调** |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `padding`/`margin` | 否 | String | 0 | [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 form / 交互容器 / column_set / collapsible_panel 内。
|
||||
- 不配置 `behaviors` 时勾选仅前端本地生效,不会触发服务端回调——需要业务侧感知必须显式配置。
|
||||
- 回调:`action.tag="checker"` + `action.checked`(布尔值);form 内则读 `form_value[name]`。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 折叠面板 `collapsible_panel`
|
||||
|
||||
折叠次要内容(备注、长文本),点标题展开/收起。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": false,
|
||||
"header": { "title": { "tag": "plain_text", "content": "面板标题" } },
|
||||
"elements": [{ "tag": "markdown", "content": "折叠的内容" }]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `collapsible_panel` |
|
||||
| `header` | 是 | Object | / | 标题区,见下 |
|
||||
| `elements` | 否 | Array | / | 面板内组件;**不能放 `form`** |
|
||||
| `expanded` | 否 | Boolean | false | 是否默认展开 |
|
||||
| `background_color` | 否 | String | 透明 | 面板背景,颜色枚举(见 `../resource/colors.md`) |
|
||||
| `border` | 否 | Object | / | `{ color, corner_radius }` |
|
||||
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
|
||||
| `vertical_spacing`/`horizontal_spacing` | 否 | String | 8px | 间距枚举或 [0,99]px |
|
||||
| `padding` | 否 | String | 0 | 内边距 [0,99]px |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
**header 字段**:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `title` | 否 | `{tag:"plain_text"\|"markdown", content}` |
|
||||
| `background_color` | 否 | 标题区背景,颜色枚举 |
|
||||
| `width` | 否 | `fill` / `auto` / `auto_when_fold`(收起时自适应) |
|
||||
| `vertical_align` | 否 | `top`/`center`/`bottom` |
|
||||
| `icon` | 否 | 图标 `{tag, token, color, size}`(同 `div.icon`,多 `size`) |
|
||||
| `icon_position` | 否 | `left` / `right` / `follow_text` |
|
||||
| `icon_expanded_angle` | 否 | 展开时图标旋转角:`-180`/`-90`/`90`/`180` |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 内部不支持 `form`;容器最多嵌套 5 层。
|
||||
- 仅支持写 JSON,搭建工具不支持。
|
||||
53
skills/lark-im/references/card/components/column_set.md
Normal file
53
skills/lark-im/references/card/components/column_set.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 分栏 `column_set` + `column`
|
||||
|
||||
横向多列布局容器。`column_set` 装若干 `column`,每个 `column` 内再放组件。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "column_set",
|
||||
"flex_mode": "none",
|
||||
"columns": [
|
||||
{ "tag": "column", "width": "weighted", "weight": 1,
|
||||
"elements": [{ "tag": "markdown", "content": "左列" }] },
|
||||
{ "tag": "column", "width": "weighted", "weight": 1,
|
||||
"elements": [{ "tag": "markdown", "content": "右列" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## column_set 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `column_set` |
|
||||
| `columns` | 是 | column[] | / | 列数组,子节点只能是 `column` |
|
||||
| `flex_mode` | 否 | String | none | 窄屏自适应:`none`(按比例压缩) / `stretch`(变上下堆叠) / `flow`(自动换行) / `bisect`(两等分) / `trisect`(三等分) |
|
||||
| `horizontal_spacing` | 否 | String | 8px | `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` |
|
||||
| `horizontal_align` | 否 | String | left | `left` / `center` / `right` |
|
||||
| `background_style` | 否 | String | default | `default` 或颜色枚举/RGBA(见 `../resource/colors.md`);嵌套时上层覆盖下层 |
|
||||
| `action` | 否 | Object | / | 整块点击跳转 `{ multi_url:{url,pc_url,ios_url,android_url} }` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
|
||||
## column 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `column` |
|
||||
| `elements` | 否 | Element[] | / | 列内组件;**不能放 `form` 和 `table`**,可放 `column_set` |
|
||||
| `width` | 否 | String | auto | 仅 `flex_mode:none` 生效:`auto` / `weighted`(配 weight) / `[16,600]px` |
|
||||
| `weight` | 否 | Number | 1 | `width:weighted` 时的宽度占比,1~5 整数 |
|
||||
| `vertical_align` | 否 | String | top | `top` / `center` / `bottom` |
|
||||
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
|
||||
| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px | 同上间距枚举或 `[0,99]px` |
|
||||
| `padding` | 否 | String | 0 | 内边距 [0,99]px |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `background_style` | 否 | String | default | 同上 |
|
||||
| `action` | 否 | Object | / | 点击列跳转,同 column_set.action |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- **column_set 的直接子节点只能是 `column`**;不能 `column_set → column_set`。二级分栏要走 `column_set → column → column_set`。
|
||||
- column 内可放除 `form` / `table` 外的所有组件。
|
||||
- 最多嵌套 5 层,过深会压缩展示空间。
|
||||
34
skills/lark-im/references/card/components/date_picker.md
Normal file
34
skills/lark-im/references/card/components/date_picker.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 日期选择器 `date_picker`
|
||||
|
||||
提供日期选项的交互组件,默认拥有交互能力(无需显式 `behaviors` 也会回调)。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "date_picker",
|
||||
"placeholder": { "tag": "plain_text", "content": "请选择" },
|
||||
"initial_date": "2024-01-01"
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `date_picker` |
|
||||
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
|
||||
| `required` | 否 | Boolean | false | 是否必选(form 内生效) |
|
||||
| `initial_date` | 否 | String | / | 初始值,格式 `yyyy-MM-dd`,会覆盖 `placeholder` |
|
||||
| `placeholder` | 否 | Object | / | 占位文本,plain_text;未设 `initial_date` 时必填 |
|
||||
| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` |
|
||||
| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+) |
|
||||
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
|
||||
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
|
||||
| `margin` | 否 | String | 0 | [-99,99]px |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。
|
||||
- 提醒用户注意时区语境(如预定海外酒店用酒店所在地时区);服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。
|
||||
- 回调:`action.tag="date_picker"` + `action.option`(日期字符串,如 `"2025-06-10 +0800"`)+ `action.timezone`;form 内则读 `form_value[name]`。
|
||||
36
skills/lark-im/references/card/components/div.md
Normal file
36
skills/lark-im/references/card/components/div.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 普通文本 `div`
|
||||
|
||||
带样式的文本块,支持前缀图标和 label-value 字段对。**Card 2.0**。富文本用 `markdown` 组件。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "div",
|
||||
"text": { "tag": "plain_text", "content": "示例文本" }
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `div` |
|
||||
| `text` | 否 | Object | / | 文本对象,见下 |
|
||||
| `text.tag` | 是 | String | plain_text | `plain_text` 或 `lark_md`(部分 Markdown,语法见 `markdown.md`) |
|
||||
| `text.content` | 是 | String | / | 文本内容 |
|
||||
| `text.text_size` | 否 | String | normal | `heading-0`~`heading-4` / `normal`(14px) / `notation`(12px) 等;可在 `config.style.text_size` 自定义 pc/mobile 不同字号 |
|
||||
| `text.text_color` | 否 | String | default | 颜色枚举(见 `../resource/colors.md`),仅 `plain_text` 生效 |
|
||||
| `text.text_align` | 否 | String | left | `left` / `center` / `right` |
|
||||
| `text.lines` | 否 | Int | / | 最大显示行数,超出 `...` 省略 |
|
||||
| `icon` | 否 | Object | / | 前缀图标,见下 |
|
||||
| `icon.tag` | 否 | String | / | `standard_icon`(用 `token`+`color`,token 见 `../resource/icons.md`)或 `custom_icon`(用 `img_key`) |
|
||||
| `width` | 否 | String | fill | `fill` / `auto` / `[16,999]px` |
|
||||
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符;流式更新时 `text.element_id` 指定文本 |
|
||||
|
||||
> `fields` 字段(多列 label-value):数组,每项 `{ is_short, text:{tag,content} }`,`is_short:true` 可并排。
|
||||
|
||||
## 易错点
|
||||
|
||||
- `text_color` 只在 `text.tag` 为 `plain_text` 时生效;`lark_md` 用内联 `<font color=red>` 着色。
|
||||
51
skills/lark-im/references/card/components/form.md
Normal file
51
skills/lark-im/references/card/components/form.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 表单容器 `form`
|
||||
|
||||
批量录入表单项后一次提交:用户在前端填写多个表单项,点击提交按钮后将所有值打包一次性回调到服务端。**Card 2.0**。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "form",
|
||||
"name": "form_1",
|
||||
"elements": [
|
||||
{ "tag": "input", "name": "reason", "required": true },
|
||||
{
|
||||
"tag": "button",
|
||||
"text": { "tag": "plain_text", "content": "提交" },
|
||||
"type": "primary",
|
||||
"form_action_type": "submit",
|
||||
"name": "Button_submit"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `form` |
|
||||
| `name` | 是 | String | / | 表单容器唯一标识,卡片内全局唯一,用于识别提交数据归属 |
|
||||
| `elements` | 是 | Element[] | [] | 子节点,支持除 `table` 和 `form` 外的所有组件 |
|
||||
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
|
||||
| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px/12px | 间距枚举 `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` |
|
||||
| `horizontal_align` | 否 | String | left | `left`/`center`/`right` |
|
||||
| `vertical_align` | 否 | String | top | `top`/`center`/`bottom` |
|
||||
| `padding`/`margin` | 否 | String | 0 | [-99,99]px,支持单值/双值/四值写法 |
|
||||
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
|
||||
|
||||
### 子组件内嵌字段(交互组件嵌在 form 内时生效)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `name` | 是 | 表单内组件唯一标识,卡片全局唯一,否则提交失败 |
|
||||
| `required` | 否 | 是否必填;为 true 且未填时点提交会本地拦截,不发起回调 |
|
||||
| `form_action_type` | 是(按钮) | `submit`(提交)/ `reset`(重置初始值);表单内按钮**不用** `behaviors` |
|
||||
|
||||
## 嵌套 / 易错点
|
||||
|
||||
- `form` 不支持嵌套 `table` 和 `form`;且 `form` 本身只能放卡片根节点下,不能被其他组件嵌套。
|
||||
- form 内所有交互组件的 `name` 必须填且全局唯一,否则提交失败。
|
||||
- 表单内必须包含一个 `form_action_type: submit` 的按钮。
|
||||
- 回调来源:`card.action.trigger` 中 `action.tag="button"` + `action.form_value`(按组件 `name` 映射各字段值)。
|
||||
34
skills/lark-im/references/card/components/header.md
Normal file
34
skills/lark-im/references/card/components/header.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 标题 `header`
|
||||
|
||||
卡片顶部标题区(主/副标题、后缀标签、图标、主题色)。**Card 2.0**。挂在卡片根的 `header` 键下,不在 `body.elements` 内,单卡仅一个。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"template": "blue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `title` | 是 | Object | 主标题,`{tag:"plain_text"\|"lark_md", content}`,最多 4 行 |
|
||||
| `subtitle` | 否 | Object | 副标题,同 title,最多 1 行;只配副标题会按主标题展示 |
|
||||
| `template` | 否 | String | 主题色枚举,见下;默认 `default` |
|
||||
| `text_tag_list` | 否 | Array | 后缀标签,最多 3 个,每项 `{tag:"text_tag", text:{tag:"plain_text",content}, color}` |
|
||||
| `i18n_text_tag_list` | 否 | Object | 多语言后缀标签;与 `text_tag_list` 二选一,同配以多语言为准 |
|
||||
| `icon` | 否 | Object | 前缀图标(同 `div.icon`) |
|
||||
| `padding` | 否 | String | 内边距,默认 12px,[0,99]px |
|
||||
|
||||
**template 枚举**(13 色):`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey` / `default`。
|
||||
|
||||
**标签 color 枚举**:`neutral`/`blue`/`turquoise`/`lime`/`orange`/`violet`/`indigo`/`wathet`/`green`/`yellow`/`red`/`purple`/`carmine`。深浅档位及 RGBA 见 `../resource/colors.md`。
|
||||
|
||||
## 选色建议
|
||||
|
||||
按场景选 template 颜色见 `../lark-im-card-style.md` 意图表。常见语义:green=成功/完成,orange=警告,red=错误/危险,grey=失效/归档,blue=通用信息。
|
||||
17
skills/lark-im/references/card/components/hr.md
Normal file
17
skills/lark-im/references/card/components/hr.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 分割线 `hr`
|
||||
|
||||
分隔卡片内容的水平线。**Card 2.0**(1.0 同名 `hr`)。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```json
|
||||
{ "tag": "hr" }
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段 | 必填 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `tag` | 是 | String | / | 固定 `hr` |
|
||||
| `margin` | 否 | String | 0 | 外边距,范围 [-99,99]px,如 `"8px 0"` |
|
||||
| `element_id` | 否 | String | / | 组件唯一标识,字母开头、≤20 字符 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user