Compare commits

..

7 Commits

Author SHA1 Message Date
sunpeiyang.996
ba497de3ba docs(lark-doc): document poll block xml no-meego
Change-Id: I1f1e3fd17618d29e40f93b00258c8afd92516acf
2026-07-09 03:01:12 +08:00
liangshuo-1
cdd9d3409b feat(affordance): usage guidance for shortcuts and per-command skills (#1793) 2026-07-08 19:45:21 +08:00
zhaojunlin0405
06f6b0b18c fix: preserve original filename in multipart file upload (#1767)
* chore: bump oapi-sdk-go/v3 to v3.7.2 for filename-aware multipart upload

* fix: preserve original filename in multipart file upload

BuildFormdata read local files into a bytes.Reader before handing them
to the SDK, so the SDK's part-filename detection (which only reads
*os.File) fell back to "unknown-file" for every local --file upload.
Use AddFileWithName with the file's basename instead.
2026-07-08 19:16:03 +08:00
max
9413e7cd8b feat(vc): refine meeting-events output and reaction forwarding (#1674)
Refine `vc +meeting-events` around a stable agent-facing output contract.

The command now exposes structured meeting metadata, current read identity, normalized event rows, warnings, and pagination fields across JSON/NDJSON/pretty output. Event rows include stable event identifiers, event time, actors, and event-specific payloads for participant, chat/reaction, transcript, and magic-share events.

Improve meeting status inference by treating participant-left events with meeting-ended leave reasons as an ended signal, and keep compatibility with payload-only event shapes by falling back to `payload.activity_event_type`.

Update `lark-vc-agent` guidance for forwarding meeting chat and reactions to IM. Agents should build Feishu post content from JSON events, emit IM `emotion` nodes only for whitelisted reaction keys, and fall back unsupported reaction keys to text.

Add focused unit and dry-run E2E coverage for the event-type fallback and `vc +meeting-events --dry-run` request shape.
2026-07-08 15:35:14 +08:00
raistlin042
047d729f72 docs: restore one-time authorization guidance in lark-apps skill (#1794) 2026-07-08 15:18:49 +08:00
chenxingyang1019
1a9f637866 fix(apps): make db --environment optional, auto-select branch server-side (#1735)
* fix(apps): make db --environment optional, auto-select branch server-side

All db shortcuts defaulted --environment to "dev", which forced single-env
apps (whose DB lives on the online branch, with no dev branch) to fail with
"Invalid DB Branch: dev" unless the user explicitly passed --environment
online.

Change the default to empty: when --environment is omitted the CLI sends no
env, letting the server pick the branch by the app's multi-env state
(multi-env → dev, single-env → online), matching miaoda-cli's behavior of
not carrying dbBranch when unset. Explicit --environment dev|online is
unchanged; explicit dev on a single-env app still errors as expected.

- 10 db shortcuts: dbEnvFlags default "dev" → "" (+db-execute, +db-table-list,
  +db-table-get, +db-quota-get, +db-data-export, +db-data-import,
  +db-changelog-list, +db-audit-list/-set/-status)
- dry-run e2e assertions updated: default env is now unset, not "dev"
- skill docs (lark-apps-db, lark-apps-db-execute) describe the auto-select

* fix(apps): omit empty --environment param; refine dry-run tests and skill doc

Address PR #1735 review:
- omit-empty: when --environment is unset, drop the env query key entirely
  instead of sending env="" — matches the family's omit-empty convention
  (cf. page_token) and miaoda-cli's "no dbBranch when unset". Add dbEnvParams
  helper; apply across all db shortcuts (execute, table-list/-get, quota-get,
  changelog-list, audit-list/-set/-status, data-export/-import) plus the
  export/import query params, queryExportTotal and audit-list table/status probes.
- e2e dry-run assertions pin env is omitted via .Exists() (was Equal "").
- skill doc (lark-apps-db): rewrite the --environment guidance from an agent's
  decision POV — read vs write, single-env writes hit online prod, explicit dev
  on single-env as a probe; drop redundant/changelog phrasing.

* fix(apps): db recovery --environment support + diff/migrate display fixes

- +db-recovery-diff/-apply: add --environment (env → query param on submit
  and both status polls), aligned with the recovery env IDL
- recovery diff: parse string row counts (inserted/deleted arrive as strings)
  so they render as "-N rows" instead of "no changes"; drop the redundant
  per-table data-row line when a schema action (drop/restore/alter) exists for
  the same table; count tables_affected by distinct tables
- +db-env-migrate: run a dry_run preview before apply to backfill the change
  count when the server reports changes_applied=0 on a cold apply (matches
  miaoda-cli's diff-then-apply)
- lark-apps-db.md: drop the redundant recovery clause (recovery follows the
  standard --environment rule)

* test(apps): cover no-env dry-run defaults + numericAsFloat string path

Address CodeRabbit review threads on PR #1735:
- numericAsFloat: add numeric-string cases ("13.5", " 13.5 ", int, empty)
- db-data-import: assert dry-run omits env when --environment unset (table
  still defaults to file basename)
- db-quota-get: assert dry-run omits env when --environment unset
2026-07-08 14:06:22 +08:00
liujinkun2025
34c4ba5581 fix: accept opaque wiki node tokens (#1789) 2026-07-08 11:40:58 +08:00
172 changed files with 2094 additions and 21452 deletions

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/dev-svglide-ppe-env.test.js scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js skills/lark-slides/scripts/svg_slides_readback_gate_test.mjs
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
## <command> the command as typed, minus `lark-cli <domain>`; a
+-prefixed heading (## +create) targets that shortcut
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### Skills bullet skill names, or name/relpath references
(lark-contact/references/x.md), to read for usage;
merged with the domain `> skill:` default (deduped,
domain first)
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
(validated against the path); help drops any that don't resolve, so a typo shows
nothing. Point a command at its own reference (e.g. `+search-user`
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
domain skill, which the `> skill:` default already covers. When a shortcut also
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
replace the Go tips (not merged), so keep tips in one place.
## Example
## messages get
@@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites`
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
so the heading must equal the shortcut command exactly (`## +history-revert`).

View File

@@ -1,6 +1,42 @@
# contact
> skill: lark-contact
## +search-user
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
### Skills
- lark-contact/references/lark-contact-search-user.md
### Avoid when
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
### Examples
**Find a user by name**
```bash
lark-cli contact +search-user --query "alice" --as user
```
**Fetch known users by open_id (me = yourself)**
```bash
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
### Skills
- lark-contact/references/lark-contact-get-user.md
### Avoid when
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
### Tips
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
- --user-id-type must match the id you pass (default open_id)
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.

View File

@@ -4,10 +4,14 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -1069,3 +1073,157 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}
// parseMultipartFilenames drives one api --file upload through the mock
// transport and returns a map of field name -> part filename parsed from the
// captured multipart body, plus the map of text form fields. It fails the test
// if the captured request is not multipart/form-data.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
fields := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
} else {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(part)
fields[part.FormName()] = buf.String()
}
}
return filenames, fields
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if _, ok := filenames["upload"]; !ok {
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
}
if got := filenames["upload"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, fields := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
}
if got := fields["type"]; got != "attachment" {
t.Fatalf("text field type = %q, want %q", got, "attachment")
}
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "unknown-file" {
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
}
}

View File

@@ -679,7 +679,11 @@ func installTipsHelpFunc(root *cobra.Command) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}

View File

@@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
// hand-authored Long when present, else the Short.
func domainHelpBase(cmd *cobra.Command) string {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return captureHelpBase(cmd, domainBaseAnnotation)
}
// captureHelpBase records a command's pristine lead text once — its
// hand-authored Long, or Short when Long is empty — into the given annotation,
// so lazy re-renders compose onto the original text instead of onto an
// already-augmented Long. This is what lets a shortcut's PostMount-authored
// Long survive: it becomes the base the affordance block is appended below.
func captureHelpBase(cmd *cobra.Command, key string) string {
if base, ok := cmd.Annotations[key]; ok {
return base
}
base := cmd.Long
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainBaseAnnotation] = base
cmd.Annotations[key] = base
return base
}
@@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
const (
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
shortcutBaseAnnotation = "affordance-shortcut-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
@@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmdmeta.SetAffordanceRef(cmd, service, methodID)
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
@@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
// pointers: each is emitted only when it resolves in the skill tree (see
// affordance.SkillStatPath), so a typo or a build without embedded skills never
// prints a `skills read` that cannot be opened.
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
var b strings.Builder
b.WriteString(cmd.Short)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
writeRisk(&b, cmd)
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
skills = a.Skills
}
}
@@ -164,17 +164,95 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
writeRelatedSkills(&b, skills, skillFS)
cmd.Long = b.String()
return true
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
// read the skill" directive) keeps it — the affordance block is appended below,
// never clobbering it.
//
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
// the overlay declares none; when the overlay has tips, the Go tips are dropped
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
// therefore silently retires that shortcut's Go Tips — consolidate into one.
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
return false
}
if len(a.Tips) == 0 {
a.Tips = cmdutil.GetTips(cmd)
}
var b strings.Builder
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
writeRisk(&b, cmd)
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
}
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
// high-risk-write commands. A no-op when the command has no risk annotation.
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
level, ok := cmdutil.GetRisk(cmd)
if !ok {
return
}
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(b, "\n\nRisk: %s", level)
}
}
// writeRelatedSkills appends the "Related skills" block for the entries that
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
// so help never prints a `skills read` pointer that cannot be opened.
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
if skillFS == nil || len(skills) == 0 {
return
}
var avail []string
for _, s := range skills {
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
}
}
if len(avail) == 0 {
return
}
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
for _, s := range avail {
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
}
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For
@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
if !ok {
return nil, false
}
return affordanceLookup(service, methodID)
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
if !ok {
return ""
}
return renderAffordanceValue(a)
}
// renderAffordanceValue renders an already-parsed affordance. Split from
// renderAffordance so callers can render a value they have adjusted first (e.g.
// a shortcut folding its declarative tips into an overlay that has none).
func renderAffordanceValue(a meta.Affordance) string {
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
@@ -70,8 +71,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
}
}
@@ -119,7 +120,7 @@ func TestPrepareMethodHelp(t *testing.T) {
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd) {
if !PrepareMethodHelp(cmd, nil) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
@@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) {
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
// non-shortcut commands) for the default help path.
func TestPrepareShortcutHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service == "calendar" && methodID == "+create" {
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
}
return nil, false
}
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
cmdutil.SetRisk(sc, "write")
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
if !strings.Contains(sc.Long, want) {
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
}
}
if strings.Contains(sc.Long, "Full parameter schema:") {
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
}
// No overlay entry -> leave it for the default help path.
bare := &cobra.Command{Use: "+bare", Short: "x"}
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
if PrepareShortcutHelp(bare, nil) {
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
}
// Non-shortcut source is ignored even with a ref.
notSc := &cobra.Command{Use: "create", Short: "x"}
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
if PrepareShortcutHelp(notSc, nil) {
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
}
}
// Related-skill pointers are gated on existence: a skill that resolves in the
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
// and a nil skill FS suppresses the whole block.
func TestRelatedSkillsStatGating(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
}
skillFS := fstest.MapFS{
"lark-real/SKILL.md": {Data: []byte("# real")},
"lark-real/references/deep.md": {Data: []byte("# deep")},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, skillFS) {
t.Fatal("PrepareMethodHelp returned false")
}
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "lark-typo") {
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
}
// A name/relpath reference to an existing file renders; a missing one drops.
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "references/missing.md") {
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
}
// nil skill FS: the whole Related-skills block is suppressed.
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
PrepareMethodHelp(bare, nil)
if strings.Contains(bare.Long, "Related skills") {
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
}
}
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
// PostMount) keeps it as the lead: the affordance block is appended below, not
// clobbered, and re-rendering does not double-append.
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
}
const authored = "Custom docs help. AI agents MUST read the skill first."
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
if !strings.HasPrefix(sc.Long, authored) {
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
}
if !strings.Contains(sc.Long, "When to use:") {
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
}
// Re-render must reuse the captured base, not append the block twice.
PrepareShortcutHelp(sc, nil)
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {

View File

@@ -4,10 +4,14 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"strings"
"testing"
@@ -1132,6 +1136,63 @@ func TestDetectFileFields(t *testing.T) {
}
}
// parseMultipartFilenames drives one service-method --file upload through the
// mock transport and returns a map of field name -> part filename parsed from
// the captured multipart body. Mirrors cmd/api's helper of the same name
// (inlined here rather than shared, since the two live in different packages)
// to give BuildFormdata's shared local-file fix a second real entry-point
// covering it.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
}
}
return filenames
}
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/im/v1/images",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
}
reg.Register(stub)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames := parseMultipartFilenames(t, stub)
if got := filenames["image"]; got != "photo.jpg" {
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)

View File

@@ -1,10 +0,0 @@
# Lark Slides Development Plans
本目录只保存开发阶段计划、评审记录和执行拆解,不属于 `lark-slides` skill 的运行时提示词或工具使用参考。
约束:
- 不要从 `skills/lark-slides/SKILL.md` 路由到本目录。
- 不要把本目录内容作为 agent 调用 `slides` 工具时的稳定协议或操作规范。
- 当计划中的结论沉淀为长期有效规则时,先拆成原子化、可验证的运行时说明,再放入 `skills/lark-slides/references/` 或其下的专题目录。
- 当计划仅用于阶段性开发判断时,继续留在本目录,避免污染工具提示词边界。

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1

4
go.sum
View File

@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -83,10 +83,9 @@ func commandFormResolver(service string) func(string) string {
}
}
return func(h string) string {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
if id, ok := byForm[strings.TrimSpace(h)]; ok {
return id
}
return strings.ReplaceAll(h, " ", ".")
return headingToKey(h) // one home for the shortcut/method key convention
}
}

View File

@@ -7,6 +7,8 @@ import (
"encoding/json"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/meta"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
@@ -84,3 +86,38 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}
// The ### Skills section merges with the domain `> skill:` default: domain
// first, then per-command entries, de-duplicated. A command with no ### Skills
// still inherits the domain default.
func TestParseDomainMD_SkillsMerge(t *testing.T) {
md := "# d\n> skill: lark-d\n\n" +
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
}
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
}
}
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
// matches the shortcut command as mounted.
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}
func keysOf(m map[string]meta.Affordance) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -19,6 +19,7 @@ import (
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### Skills -> skills: bullet skill names, added to the domain default
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
@@ -34,16 +35,56 @@ var standardSection = map[string]string{
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
"Skills": "skills",
}
// mergeSkills returns the domain-default skill followed by a command's own skill
// entries, de-duplicated in author order and empties dropped. Backticks (left by
// the shared bullet parse) are stripped so each entry is a bare skill name.
func mergeSkills(domain string, extra []string) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.Trim(strings.TrimSpace(s), "`")
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(domain)
for _, s := range extra {
add(s)
}
return out
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
// while an entry containing a slash is a name/relative-path reference (e.g.
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
// skills read already accepts — so a per-command entry can point at that
// command's own reference file, not just re-point the domain skill.
func SkillStatPath(entry string) string {
if strings.Contains(entry, "/") {
return entry
}
return entry + "/SKILL.md"
}
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
h = strings.TrimSpace(h)
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
return h
}
return strings.ReplaceAll(h, " ", ".")
}
type mdSection struct {
@@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
var perCmdSkills []string
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
@@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
a.Tips = s.items
case "examples":
a.Examples = s.cases
case "skills":
perCmdSkills = s.items
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if skill != "" {
a.Skills = []string{skill}
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
a.Skills = s
}
out[curKey] = a
}
@@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
pending = ""
}
continue

View File

@@ -2,9 +2,11 @@
// SPDX-License-Identifier: MIT
// Package cmdmeta is the single source of truth for command metadata that the
// policy engine and the hook selector both consume. It wraps the existing
// cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need.
// policy engine, the hook selector, and help rendering consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need, plus the
// affordance ref (service, method id) that lets service-method and shortcut
// help share one usage-guidance lookup path.
//
// Three axes:
//
@@ -51,6 +53,12 @@ const (
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
// affordance{Service,Method}Key locate the command's usage-guidance overlay
// entry (see internal/affordance). Both service-method commands and
// +-prefixed shortcuts set these so help rendering shares one lookup path.
affordanceServiceKey = "cmdmeta.affordance.service"
affordanceMethodKey = "cmdmeta.affordance.method"
)
// Meta groups the three command-level metadata axes consumed by the policy
@@ -125,6 +133,35 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
}
}
// SetAffordanceRef records which affordance overlay entry (service, method id)
// a command maps to, so help rendering can look up its usage guidance. Stored
// on the command itself (no inheritance): each method / shortcut owns its ref.
// A no-op if either coordinate is empty.
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
if service == "" || method == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[affordanceServiceKey] = service
cmd.Annotations[affordanceMethodKey] = method
}
// AffordanceRef returns the command's own affordance overlay coordinates.
// ok is false when the command carries no ref.
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
service = cmd.Annotations[affordanceServiceKey]
method = cmd.Annotations[affordanceMethodKey]
if service == "" || method == "" {
return "", "", false
}
return service, method, true
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
@@ -128,7 +129,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
WithParam("--file").
WithCause(err)
}
fd.AddFile(fieldName, bytes.NewReader(data))
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.

View File

@@ -8,8 +8,11 @@ import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
// each against the embedded skill tree (a name → its SKILL.md, a reference →
// that path) and drops any that do not resolve.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`

View File

@@ -1,197 +0,0 @@
#!/usr/bin/env sh
RULE_GROUP="lark-cli"
CANONICAL_RULE_NAME="lark-cli-openapi-ppe-svg-slides"
PROXY_HOST="${LARK_CLI_PROXY_HOST:-127.0.0.1}"
PROXY_PORT="${LARK_CLI_PROXY_PORT:-8899}"
fail() {
printf '%s\n' "[svglide-ppe] $*" >&2
return 1 2>/dev/null || exit 1
}
info() {
printf '%s\n' "[svglide-ppe] $*" >&2
}
usage() {
cat <<USAGE
Usage:
. ./scripts/dev-svglide-ppe-env.sh pre
. ./scripts/dev-svglide-ppe-env.sh off
./scripts/dev-svglide-ppe-env.sh rules
Notes:
- Use "source" or "." for pre/off so proxy env vars apply to the current shell.
- pre writes the canonical Whistle rule "${CANONICAL_RULE_NAME}".
- off disables only the canonical Whistle rule "${CANONICAL_RULE_NAME}".
- The only PPE lane is "ppe_svg_slides".
- This is dev-only PPE plumbing, not production publish behavior.
USAGE
}
is_sourced() {
if [ -n "${BASH_VERSION-}" ]; then
[ "${BASH_SOURCE[0]}" != "$0" ]
return
fi
if [ -n "${ZSH_VERSION-}" ]; then
case "${ZSH_EVAL_CONTEXT-}" in
*:file:*) return 0 ;;
*) return 1 ;;
esac
fi
return 1
}
pick_whistle_cmd() {
if command -v w2 >/dev/null 2>&1; then
printf '%s\n' "w2"
return 0
fi
if command -v whistle >/dev/null 2>&1; then
printf '%s\n' "whistle"
return 0
fi
info "missing required command: w2 (or whistle)"
info "install: npm install -g whistle"
info "then: w2 start && w2 ca"
fail "missing required command: w2 (or whistle)"
}
ensure_whistle_running() {
wcmd="$1"
whistle_status="$("$wcmd" status 2>&1 || true)"
case "$whistle_status" in
*"No running Whistle instances"*)
info "Whistle is not running, starting it now..."
"$wcmd" start >/dev/null 2>&1 || fail "failed to start Whistle"
;;
esac
}
canonical_rules() {
cat <<'RULES'
/^https:\/\/open\.feishu\.cn\/(.*)$/ https://open.feishu-pre.cn/$1
https://open.feishu.cn/ reqHeaders://Env=Pre_release
https://open.feishu.cn/ reqHeaders://x-tt-env=ppe_svg_slides
https://open.feishu.cn/ reqHeaders://x-use-ppe=1
https://open.feishu-pre.cn/ reqHeaders://Env=Pre_release
https://open.feishu-pre.cn/ reqHeaders://x-tt-env=ppe_svg_slides
https://open.feishu-pre.cn/ reqHeaders://x-use-ppe=1
/^https:\/\/accounts\.feishu\.cn\/(.*)$/ https://accounts.feishu-pre.cn/$1
RULES
}
disabled_rules() {
cat <<RULES
# disabled by scripts/dev-svglide-ppe-env.sh
# canonical SVGlide PPE rule: ${CANONICAL_RULE_NAME}
RULES
}
write_rule_js() {
output="$1"
rule_name="$2"
rules="$3"
RULE_NAME="$rule_name" RULE_GROUP="$RULE_GROUP" RULES="$rules" node >"$output" <<'NODE'
const rule = {
name: process.env.RULE_NAME,
groupName: process.env.RULE_GROUP,
rules: process.env.RULES || "",
};
process.stdout.write(`module.exports = function (callback) {
callback(${JSON.stringify(rule, null, 2)});
};
`);
NODE
}
create_temp_rule_js() {
tmp_base="$(mktemp "${TMPDIR:-/tmp}/svglide-ppe-rule.XXXXXX")" || fail "failed to create temp file"
tmp_js="${tmp_base}.js"
mv "$tmp_base" "$tmp_js" || {
rm -f "$tmp_base" "$tmp_js"
fail "failed to create temp file"
}
printf '%s\n' "$tmp_js"
}
apply_rule() {
wcmd="$1"
rule_name="$2"
rules="$3"
tmp_js="$(create_temp_rule_js)" || return 1
write_rule_js "$tmp_js" "$rule_name" "$rules"
"$wcmd" add "$tmp_js" --force >/dev/null 2>&1 || {
rm -f "$tmp_js"
fail "failed to apply Whistle rule: ${rule_name}"
}
rm -f "$tmp_js"
}
apply_proxy_env() {
proxy_url="http://${PROXY_HOST}:${PROXY_PORT}"
export HTTPS_PROXY="$proxy_url"
export https_proxy="$proxy_url"
export HTTP_PROXY="$proxy_url"
export http_proxy="$proxy_url"
export ALL_PROXY="$proxy_url"
export all_proxy="$proxy_url"
unset LARK_CLI_NO_PROXY
info "proxy env enabled: ${proxy_url}"
}
clear_proxy_env() {
unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy
unset LARK_CLI_NO_PROXY
info "proxy env cleared"
}
mode="${1:-}"
[ "$mode" = "" ] && {
usage
return 0 2>/dev/null || exit 0
}
shift || true
[ "$#" -eq 0 ] || fail "unexpected args for mode ${mode}"
case "$mode" in
rules)
canonical_rules
;;
pre)
wcmd="$(pick_whistle_cmd)"
ensure_whistle_running "$wcmd"
apply_rule "$wcmd" "$CANONICAL_RULE_NAME" "$(canonical_rules)"
info "applied canonical Whistle rule: ${CANONICAL_RULE_NAME}"
if is_sourced; then
apply_proxy_env
else
info "Whistle rule updated, but current shell env is unchanged."
info "run with source: . ./scripts/dev-svglide-ppe-env.sh pre"
fi
info "active PPE lane: ppe_svg_slides"
;;
off|cancel)
wcmd="$(pick_whistle_cmd)"
ensure_whistle_running "$wcmd"
apply_rule "$wcmd" "$CANONICAL_RULE_NAME" "$(disabled_rules)"
info "disabled canonical Whistle rule: ${CANONICAL_RULE_NAME}"
if is_sourced; then
clear_proxy_env
else
info "Whistle rules disabled, but current shell env is unchanged."
info "run with source: . ./scripts/dev-svglide-ppe-env.sh off"
fi
;;
-h|--help|help)
usage
;;
*)
fail "invalid mode: ${mode}; expected pre|off|rules"
;;
esac

View File

@@ -1,116 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
function createMockWhistle() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "svglide-ppe-test-"));
const binDir = path.join(tmp, "bin");
const logPath = path.join(tmp, "w2.log");
fs.mkdirSync(binDir);
fs.writeFileSync(path.join(binDir, "w2"), `#!/usr/bin/env sh
case "$1" in
status)
printf '%s\\n' "Whistle is running"
;;
add)
node -e 'require(process.argv[1])((rule) => console.log(JSON.stringify({ name: rule.name, rules: rule.rules })))' "$2" >>"$SVGLIDE_TEST_W2_LOG"
;;
start)
printf '%s\\n' "START" >>"$SVGLIDE_TEST_W2_LOG"
;;
*)
printf 'unexpected w2 args: %s\\n' "$*" >&2
exit 2
;;
esac
`);
fs.chmodSync(path.join(binDir, "w2"), 0o755);
return { binDir, logPath };
}
function readLoggedRules(logPath) {
return fs
.readFileSync(logPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
}
describe("dev-svglide-ppe-env", () => {
it("keeps one canonical rule name in the script source", () => {
const source = fs.readFileSync(path.join(repoRoot, "scripts/dev-svglide-ppe-env.sh"), "utf8");
assert.match(source, /CANONICAL_RULE_NAME="lark-cli-openapi-ppe-svg-slides"/);
assert.doesNotMatch(
source,
/lark-cli-openapi-env-switch|lark-cli-openapi-ppe-svglide|lark-cli-openapi-ppe-pure-svg|lark-cli-ppe-svglide|lark-cli-prod-ppe/
);
});
it("prints only the canonical SVG Slides PPE lane", () => {
const result = spawnSync("sh", ["scripts/dev-svglide-ppe-env.sh", "rules"], {
cwd: repoRoot,
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /open\.feishu-pre\.cn/);
assert.match(result.stdout, /x-tt-env=ppe_svg_slides/);
assert.match(result.stdout, /x-use-ppe=1/);
assert.doesNotMatch(result.stdout, /ppe_pure_svg|ppe_svglide/);
});
it("keeps pre mode to one written Whistle rule", () => {
const { binDir, logPath } = createMockWhistle();
const result = spawnSync("sh", ["-c", ". ./scripts/dev-svglide-ppe-env.sh pre"], {
cwd: repoRoot,
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
SVGLIDE_TEST_W2_LOG: logPath,
},
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
const rules = readLoggedRules(logPath);
assert.equal(rules.length, 1);
assert.equal(rules[0].name, "lark-cli-openapi-ppe-svg-slides");
assert.match(rules[0].rules, /x-tt-env=ppe_svg_slides/);
assert.doesNotMatch(rules[0].rules, /ppe_pure_svg|ppe_svglide/);
});
it("keeps off mode to one disabled canonical Whistle rule", () => {
const { binDir, logPath } = createMockWhistle();
const result = spawnSync("sh", ["-c", ". ./scripts/dev-svglide-ppe-env.sh off"], {
cwd: repoRoot,
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
SVGLIDE_TEST_W2_LOG: logPath,
},
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
const rules = readLoggedRules(logPath);
assert.equal(rules.length, 1);
assert.equal(rules[0].name, "lark-cli-openapi-ppe-svg-slides");
assert.match(rules[0].rules, /disabled by scripts\/dev-svglide-ppe-env\.sh/);
assert.match(rules[0].rules, /canonical SVGlide PPE rule: lark-cli-openapi-ppe-svg-slides/);
assert.doesNotMatch(rules[0].rules, /legacy|ppe_pure_svg|ppe_svglide/);
});
});

View File

@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
existing := map[string]bool{}
token := ""
for {
params := map[string]interface{}{"env": env, "page_size": 100}
params := map[string]interface{}{"page_size": 100}
if env != "" {
params["env"] = env
}
if token != "" {
params["page_token"] = token
}
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
// fetchAuditEnabledTables 拉审计状态返回当前已开启审计的表名集合status 命令同源接口)。
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
statusParams := map[string]interface{}{}
if env != "" {
statusParams["env"] = env
}
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
if err != nil {
return nil, err
}
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
// buildAuditListParams 组装 audit_list 查询参数env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
params := dbEnvParams(rctx, map[string]interface{}{
"tables": strings.Join(tables, ","),
"page_size": rctx.Int("page-size"),
}
})
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Enable table audit").
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
stop := rctx.StartSpinner("Enabling audit logging for " + table)
defer stop()
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": dbEnv(rctx)},
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
stop()
if err != nil {
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Disable table audit").
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
}
table := strings.TrimSpace(rctx.Str("table"))
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": dbEnv(rctx)},
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"table": table, "enabled": false})
if err != nil {
return withAppsHint(err, dbAuditSetHint)

View File

@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
// buildAuditStatusParams 组装 audit_status 查询参数env 及可选 table单表查询
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": dbEnv(rctx)}
params := dbEnvParams(rctx, map[string]interface{}{})
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
params["table"] = t
}

View File

@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
// buildChangelogParams 组装 changelog_list 查询参数env / page_size 及可选 table/change_id/since/until/page_token。
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
params := dbEnvParams(rctx, map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
})
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
return common.NewDryRunAPI().
GET(appDataExportPath(appID)).
Desc("Export Miaoda app table data (raw bytes)").
Params(map[string]interface{}{
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
Params(dbEnvParams(rctx, map[string]interface{}{
"table": strings.TrimSpace(rctx.Str("table")),
"format": format, "limit": rctx.Int("limit"),
})
}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
// total 查询失败不阻断导出——回退到按导出文件内容数行。
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
exportQuery := larkcore.QueryParams{
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
}
if env := dbEnv(rctx); env != "" {
exportQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{dbEnv(rctx)},
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
},
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: exportQuery,
})
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
// queryExportTotal 调 GetAppTableRecordListpage_size=1取 total符合条件的记录总数
// 该接口与 +db-data-export 同为 spark:app:read scope避免导出命令被迫升级到写权限。
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
map[string]interface{}{"env": env, "page_size": 1}, nil)
params := map[string]interface{}{"page_size": 1}
if env != "" {
params["env"] = env
}
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
if err != nil {
return 0, err
}

View File

@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
return common.NewDryRunAPI().
POST(appDataImportPath(appID)).
Desc("Import data file into Miaoda app table (multipart upload)").
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
fd.AddField("file_name", fileName)
fd.AddFile("file", bytes.NewReader(content))
importQuery := larkcore.QueryParams{"table": []string{table}}
if env := dbEnv(rctx); env != "" {
importQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: appDataImportPath(appID),
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
QueryParams: importQuery,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {

View File

@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
}
}
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
p := env.API[0].Params
if _, ok := p["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
}
if p["table"] != "orders" {
t.Fatalf("table should still default to file basename, got params=%v", p)
}
}
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
func TestAppsDBDataImport_Success(t *testing.T) {
chdirTemp(t)

View File

@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
if err != nil {
return err
}
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply服务端在未经
// dry_run 预热时直接 apply虽发布成功却把 changes_applied 回填成 0展示「Migrated (0 changes)」)。
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
// 交由下面真实 apply 统一报同样的业务错。
pending := 0
var previewFrom, previewTo string
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
pending = len(projectMigrationChanges(preview["changes"]))
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
}
stop := rctx.StartSpinner("Applying migration (dev → online)")
defer stop()
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
return withAppsHint(err, dbEnvMigrateHint)
}
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
if from == "" {
from = previewFrom
}
if to == "" {
to = previewTo
}
taskID := common.GetString(submit, "task_id")
applied := intFromAny(submit["changes_applied"])
if applied == 0 {
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
applied = n
}
}
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
if applied == 0 && pending > 0 {
applied = pending
}
stop() // clear spinner before printing the result
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
rctx.OutFormat(out, nil, func(w io.Writer) {

View File

@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
// 异步submit 返 task_idstatus 立刻 applied → CLI 对外统一 migrated。
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL,
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
}
// 配额未对接storage_quota_bytes=0→ json 删 quota/usage_percent仅留已用量与 tables/views。
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
// query 不带 env 键(交服务端按应用形态自动选分支)。
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBQuotaGet,
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbQuotaURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
}
}
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
//
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"env": dbEnv(rctx),
return dbEnvParams(rctx, map[string]interface{}{
"transactional": false,
}
})
}
// resolveExecuteSQL 返回要执行的 SQL在用时DryRun/Execute现读使 --file 的内容

View File

@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
return common.NewDryRunAPI().
GET(appDbQuotaPath(appID)).
Desc("Get Miaoda app database storage usage").
Params(map[string]interface{}{"env": dbEnv(rctx)})
Params(dbEnvParams(rctx, map[string]interface{}{}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}

View File

@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
target := rctx.Str("target")
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
if err != nil {
return withAppsHint(err, dbRecoveryHint)
}
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
}
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
if err != nil {
return nil, withAppsHint(err, dbRecoveryHint)
}
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
}
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "preview_status")) {
@@ -195,13 +203,13 @@ type recoveryChange struct {
// recoveryDiffOutput 组装 diff 输出target / tables_affected / changes[] / estimated_seconds。
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
arr, _ := preview["changes"].([]interface{})
changes := make([]recoveryChange, 0, len(arr))
raw := make([]recoveryChange, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
changes = append(changes, recoveryChange{
raw = append(raw, recoveryChange{
Table: common.GetString(m, "table"),
Inserted: m["inserted"],
Deleted: m["deleted"],
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
DroppedAt: common.GetString(m, "dropped_at"),
})
}
tablesAffected := intFromAny(preview["tables_affected"])
if tablesAffected == 0 {
tablesAffected = len(changes)
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
// 两行 + tables_affected 翻倍。
hasSchema := map[string]bool{}
for _, c := range raw {
if c.Action != "" {
hasSchema[c.Table] = true
}
}
changes := make([]recoveryChange, 0, len(raw))
for _, c := range raw {
if c.Action == "" && hasSchema[c.Table] {
continue
}
changes = append(changes, c)
}
// tables_affected 按去重后的不同表数计(而非变更条数)。
seen := map[string]bool{}
for _, c := range changes {
seen[c.Table] = true
}
est := intFromAny(preview["estimated_seconds"])
if est == 0 {
est = 30 // PRD 兜底
}
return map[string]interface{}{
"target": target, "tables_affected": tablesAffected,
"target": target, "tables_affected": len(seen),
"changes": changes, "estimated_seconds": est,
}
}

View File

@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": dbEnv(rctx)}
params := dbEnvParams(rctx, map[string]interface{}{})
if rctx.Format == "pretty" {
params["format"] = "ddl"
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
}
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
params := dbEnvParams(rctx, map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
})
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
return 0, false
}
return f, true
case string:
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
s := strings.TrimSpace(v)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
case nil:
return 0, false
}

View File

@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
{"json.Number valid", json.Number("13.5"), 13.5, true},
{"json.Number invalid", json.Number("abc"), 0, false},
{"nil", nil, 0, false},
{"unsupported string", "x", 0, false},
{"non-numeric string", "x", 0, false},
{"numeric string", "13.5", 13.5, true},
{"numeric string int", "2", 2, true},
{"numeric string padded", " 13.5 ", 13.5, true},
{"empty string", "", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
return rctx.Str("environment")
}
// dbEnvParams 把 env 并入 params仅当显式指定了环境非空才带 env 键;未指定(空)时
// 省略该键由服务端按应用多环境状态自动选分支多环境→dev单环境→online。与家族对
// 空可选参数的 omit-empty 约定一致——不发空串wire 上真正不带 env。原样返回同一个 map 便于链式。
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
if env := dbEnv(rctx); env != "" {
params["env"] = env
}
return params
}
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env显式传了就报清晰的 validation 错,指向 --environment。
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
if rctx.Changed("env") {

View File

@@ -889,6 +889,7 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
}
}
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)

View File

@@ -150,12 +150,10 @@ var ContactSearchUser = common.Shortcut{
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
},
Tips: []string{
"Keyword search: lark-cli contact +search-user --query 'alice'",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateSearchUser(runtime)

View File

@@ -9,7 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
SlidesCreateSVGlide,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,

View File

@@ -18,9 +18,7 @@ import (
const (
defaultPresentationWidth = 960
defaultPresentationHeight = 540
// maxSlidesPerCreate limits only inline +create --slides JSON payloads.
// SVGlide manifest publishing has a separate manifest-file limit.
maxSlidesPerCreate = 10
maxSlidesPerCreate = 10
)
// SlidesCreate creates a new Lark Slides presentation with bot auto-grant.
@@ -41,7 +39,7 @@ var SlidesCreate = common.Shortcut{
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10 for inline XML; for more plain XML pages, create first then add via xml_presentation.slide.create; SVGlide manifests use +create-svglide). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token."},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token."},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if slidesStr := runtime.Str("slides"); slidesStr != "" {

View File

@@ -1,164 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesCreateSVGlide publishes a validator-ready SVGlide manifest as raw SVG pages.
var SlidesCreateSVGlide = common.Shortcut{
Service: "slides",
Command: "+create-svglide",
Description: "Create a Lark Slides presentation from an existing SVGlide manifest",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Flags: []common.Flag{
{Name: "manifest", Desc: "SVGlide manifest JSON file; version=svglide.manifest.v1, 1-50 pages; separate from +create --slides max 10", Required: true},
{Name: "title", Desc: "presentation title override; defaults to manifest.title, then Untitled"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := prepareSVGlidePublishSpec(runtime, runtime.Str("manifest"), runtime.Str("title"))
if err != nil {
return err
}
_, err = planSVGlideImageRewrites(runtime, spec)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := prepareSVGlidePublishSpec(runtime, runtime.Str("manifest"), runtime.Str("title"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
imagePlan, err := planSVGlideImageRewrites(runtime, spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
total := 1 + len(imagePlan.UniqueFiles) + len(spec.Pages)
dry := common.NewDryRunAPI()
dry.Desc(fmt.Sprintf("Create SVGlide presentation + add %d page(s)", len(spec.Pages))).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(spec.Title)},
})
for i, file := range imagePlan.UniqueFiles {
appendSlidesUploadDryRun(dry, file.FilePath, "<xml_presentation_id>", i+2)
}
pageStepStart := 2 + len(imagePlan.UniqueFiles)
for i, page := range spec.Pages {
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add SVGlide page %d (%s)", pageStepStart+i, total, i+1, page.ID)).
Params(map[string]interface{}{"revision_id": -1}).
Body(map[string]interface{}{
"slide": map[string]interface{}{
"content": fmt.Sprintf("<raw SVG omitted: source_sha256=%s source_bytes=%d>", page.SHA256, page.SourceBytes),
"content_type": "svg",
"source_bytes": page.SourceBytes,
"source_sha256": page.SHA256,
"submitted_bytes": "<computed after image upload>",
"submitted_sha256": "<computed after image upload>",
"content_omitted": true,
},
})
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
}
return dry.Set("manifest", runtime.Str("manifest")).Set("svglide_manifest_version", svglideManifestVersion)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := prepareSVGlidePublishSpec(runtime, runtime.Str("manifest"), runtime.Str("title"))
if err != nil {
return err
}
imagePlan, err := planSVGlideImageRewrites(runtime, spec)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(spec.Title)},
},
)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create-svglide returned no xml_presentation_id")
}
submitPages, imageRewrite, err := uploadAndRewriteSVGlideImages(runtime, spec, imagePlan, presentationID)
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("uploading SVGlide image assets failed; presentation %s was created, no pages were added", presentationID))
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": spec.Title,
"manifest": spec.ManifestPath,
"svglide_manifest_version": svglideManifestVersion,
"images_uploaded": imageRewrite.UploadedCount,
"image_rewrites": imageRewrite.Entries,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
}
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
slideIDs := make([]string, 0, len(submitPages))
pageResults := make([]map[string]interface{}, 0, len(submitPages))
for i, page := range submitPages {
slideData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": page.Content},
},
)
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("adding SVGlide page %d/%d (%s) failed; presentation %s was created, %d page(s) added before failure", i+1, len(submitPages), page.ID, presentationID, i))
}
slideID := common.GetString(slideData, "slide_id")
if slideID != "" {
slideIDs = append(slideIDs, slideID)
}
pageResults = append(pageResults, map[string]interface{}{
"id": page.ID,
"index": page.Index,
"file": page.File,
"source_sha256": page.SHA256,
"source_bytes": page.SourceBytes,
"submitted_sha256": sha256Hex([]byte(page.Content)),
"submitted_bytes": page.ContentBytes,
"slide_id": slideID,
})
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
result["pages"] = pageResults
if url := common.GetString(data, "url"); url != "" {
result["url"] = url
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
runtime.Out(result, nil)
return nil
},
}

View File

@@ -1,276 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
type svglideImageRef struct {
PageID string
PageFile string
AttrName string
Href string
}
type svglideImageFile struct {
FilePath string
FileName string
Size int64
}
type svglideResolvedImageRef struct {
Ref svglideImageRef
FilePath string
FileName string
Size int64
}
type svglideImagePlan struct {
Refs []svglideResolvedImageRef
UniqueFiles []svglideImageFile
}
type svglideImageRewriteEntry struct {
PageID string `json:"page_id"`
PageFile string `json:"page_file"`
AttrName string `json:"attr_name"`
OriginalHref string `json:"original_href"`
ResolvedFile string `json:"resolved_file"`
FileToken string `json:"file_token"`
Size int64 `json:"size"`
}
type svglideImageRewriteResult struct {
UploadedCount int `json:"uploaded_count"`
Entries []svglideImageRewriteEntry `json:"entries"`
}
func planSVGlideImageRewrites(runtime *common.RuntimeContext, spec *svglidePublishSpec) (svglideImagePlan, error) {
var plan svglideImagePlan
seenFiles := map[string]bool{}
for _, page := range spec.Pages {
refs, err := collectSVGlideImageRefs(page)
if err != nil {
return svglideImagePlan{}, err
}
for _, ref := range refs {
resolved, err := resolveSVGlideImageRef(runtime, spec, page, ref)
if err != nil {
return svglideImagePlan{}, err
}
plan.Refs = append(plan.Refs, resolved)
if !seenFiles[resolved.FilePath] {
seenFiles[resolved.FilePath] = true
plan.UniqueFiles = append(plan.UniqueFiles, svglideImageFile{
FilePath: resolved.FilePath,
FileName: resolved.FileName,
Size: resolved.Size,
})
}
}
}
return plan, nil
}
func collectSVGlideImageRefs(page svglidePublishPage) ([]svglideImageRef, error) {
decoder := xml.NewDecoder(bytes.NewReader([]byte(page.Content)))
var refs []svglideImageRef
for {
token, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file invalid XML while scanning image hrefs: %s: %v", page.File, err).WithParam("--manifest").WithCause(err)
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "image" {
continue
}
for _, attr := range start.Attr {
if attr.Name.Local != "href" {
continue
}
attrName := "href"
if attr.Name.Space != "" {
attrName = "xlink:href"
}
refs = append(refs, svglideImageRef{
PageID: page.ID,
PageFile: page.File,
AttrName: attrName,
Href: attr.Value,
})
}
}
return refs, nil
}
func resolveSVGlideImageRef(runtime *common.RuntimeContext, spec *svglidePublishSpec, page svglidePublishPage, ref svglideImageRef) (svglideResolvedImageRef, error) {
href := strings.TrimSpace(ref.Href)
if href == "" {
return svglideResolvedImageRef{}, svglideImageHrefError("empty image href", ref)
}
lower := strings.ToLower(href)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
return svglideResolvedImageRef{}, svglideImageHrefError("remote image href is not supported", ref)
case strings.HasPrefix(lower, "data:"):
return svglideResolvedImageRef{}, svglideImageHrefError("data image href is not supported", ref)
case strings.HasPrefix(href, "#"):
return svglideResolvedImageRef{}, svglideImageHrefError("fragment image href is not supported", ref)
case strings.HasPrefix(href, "@"):
return svglideResolvedImageRef{}, svglideImageHrefError("@path image href is not an SVGlide protocol", ref)
}
resolved, err := resolveSVGlideImagePath(spec, page, href)
if err != nil {
return svglideResolvedImageRef{}, err
}
stat, err := runtime.FileIO().Stat(resolved)
if err != nil {
return svglideResolvedImageRef{}, slidesInputStatError(err, "--manifest", fmt.Sprintf("--manifest image href %q: file not found", href))
}
if stat.IsDir() {
return svglideResolvedImageRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest image href %q resolves to a directory", href).WithParam("--manifest")
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return svglideResolvedImageRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest image href %q file size %s exceeds 20 MB limit for slides image upload", href, common.FormatSize(stat.Size())).WithParam("--manifest")
}
return svglideResolvedImageRef{
Ref: ref,
FilePath: resolved,
FileName: filepath.Base(resolved),
Size: stat.Size(),
}, nil
}
func uploadAndRewriteSVGlideImages(runtime *common.RuntimeContext, spec *svglidePublishSpec, plan svglideImagePlan, presentationID string) ([]svglidePublishPage, svglideImageRewriteResult, error) {
tokens := map[string]string{}
for i, file := range plan.UniqueFiles {
fmt.Fprintf(runtime.IO().ErrOut, "Uploading SVGlide image %d/%d: %s (%s)\n", i+1, len(plan.UniqueFiles), file.FileName, common.FormatSize(file.Size))
token, err := uploadSlidesMedia(runtime, file.FilePath, file.FileName, file.Size, presentationID)
if err != nil {
return nil, svglideImageRewriteResult{UploadedCount: i}, err
}
tokens[file.FilePath] = token
}
pages := make([]svglidePublishPage, len(spec.Pages))
copy(pages, spec.Pages)
result := svglideImageRewriteResult{UploadedCount: len(plan.UniqueFiles)}
for i := range pages {
for _, ref := range plan.Refs {
if ref.Ref.PageID != pages[i].ID || ref.Ref.PageFile != pages[i].File {
continue
}
token := tokens[ref.FilePath]
rewritten, err := rewriteSVGlideImageHref(pages[i].Content, ref, token)
if err != nil {
return nil, result, err
}
pages[i].Content = rewritten
pages[i].ContentBytes = len([]byte(rewritten))
result.Entries = append(result.Entries, svglideImageRewriteEntry{
PageID: ref.Ref.PageID,
PageFile: ref.Ref.PageFile,
AttrName: ref.Ref.AttrName,
OriginalHref: ref.Ref.Href,
ResolvedFile: ref.FilePath,
FileToken: token,
Size: ref.Size,
})
}
}
return pages, result, nil
}
func rewriteSVGlideImageHref(content string, ref svglideResolvedImageRef, fileToken string) (string, error) {
imageTags := regexp.MustCompile(`(?is)<image\b[^>]*>`).FindAllStringIndex(content, -1)
for _, loc := range imageTags {
tag := content[loc[0]:loc[1]]
rewrittenTag, ok := rewriteSVGlideImageHrefInTag(tag, ref.Ref.AttrName, ref.Ref.Href, fileToken)
if !ok {
continue
}
return content[:loc[0]] + rewrittenTag + content[loc[1]:], nil
}
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to rewrite SVGlide image href %q in %s", ref.Ref.Href, ref.Ref.PageFile)
}
func rewriteSVGlideImageHrefInTag(tag, attrName, oldHref, newHref string) (string, bool) {
attr := regexp.QuoteMeta(attrName)
old := regexp.QuoteMeta(oldHref)
doubleQuoted := regexp.MustCompile(`(?s)(\b` + attr + `\s*=\s*)"` + old + `"`)
if doubleQuoted.MatchString(tag) {
return doubleQuoted.ReplaceAllString(tag, `${1}"`+newHref+`"`), true
}
singleQuoted := regexp.MustCompile(`(?s)(\b` + attr + `\s*=\s*)'` + old + `'`)
if singleQuoted.MatchString(tag) {
return singleQuoted.ReplaceAllString(tag, `${1}'`+newHref+`'`), true
}
return tag, false
}
func resolveSVGlideImagePath(spec *svglidePublishSpec, page svglidePublishPage, href string) (string, error) {
var candidate string
if strings.HasPrefix(strings.ToLower(href), "file://") {
u, err := url.Parse(href)
if err != nil || u.Host != "" || u.Path == "" {
return "", svglideImageHrefError("file URL image href is invalid", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
}
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; FileIO still validates the resolved relative path before reading.
if err != nil {
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve SVGlide image href: cannot determine working directory: %v", err).WithCause(err)
}
rel, err := filepath.Rel(cwd, filepath.Clean(u.Path))
if err != nil {
return "", svglideImageHrefError("file URL image href cannot be made relative to the working directory", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
}
candidate = rel
} else {
candidate = filepath.Join(filepath.Dir(page.File), href)
}
candidate = filepath.Clean(candidate)
if candidate == "." || hasParentDirSegment(candidate) {
return "", svglideImageHrefError("image href resolves outside the manifest root", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
}
if !svglidePathUnderRoot(candidate, spec.BaseDir) {
return "", svglideImageHrefError("image href resolves outside the manifest root", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href})
}
return candidate, nil
}
func svglidePathUnderRoot(path string, root string) bool {
root = filepath.Clean(strings.TrimSpace(root))
if root == "." {
root = ""
}
if root == "" {
return !hasParentDirSegment(path)
}
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != "." && !hasParentDirSegment(rel)
}
func svglideImageHrefError(reason string, ref svglideImageRef) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s: %s in %s", reason, ref.Href, ref.PageFile).WithParam("--manifest")
}

View File

@@ -1,338 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const (
svglideManifestVersion = "svglide.manifest.v1"
svglideBundleProtocol = "svg-slides.v1"
svglideSVGNamespace = "http://www.w3.org/2000/svg"
// +create-svglide consumes a manifest file and submits pages sequentially;
// do not reuse the inline +create --slides shell-argument limit here.
maxSVGlideManifestPages = 50
)
type svglideManifest struct {
Version string `json:"version"`
Protocol string `json:"protocol"`
Title string `json:"title"`
Size svglideManifestSize `json:"size"`
PublishReady bool `json:"publish_ready"`
Published bool `json:"published"`
Pages []svglideManifestPage `json:"pages"`
Receipts svglideManifestReceipts `json:"receipts"`
}
type svglideManifestSize struct {
Width int `json:"width"`
Height int `json:"height"`
}
type svglideManifestReceipts struct {
ValidateSVGDeck string `json:"validate_svg_deck"`
PromptReadTrace string `json:"prompt_read_trace"`
AssetReadiness string `json:"asset_readiness"`
TypographyPlan string `json:"typography_plan"`
TypographyPlanReport string `json:"typography_plan_report"`
VisualSystem string `json:"visual_system"`
VisualSystemReport string `json:"visual_system_report"`
ColorRhythm string `json:"color_rhythm"`
FrameDiscipline string `json:"frame_discipline"`
ParserContract string `json:"parser_contract"`
ReadabilitySafety string `json:"readability_safety"`
}
type svglideManifestPage struct {
ID string `json:"id"`
Index int `json:"index"`
File string `json:"file"`
SHA256 string `json:"sha256"`
}
type svglidePublishSpec struct {
ManifestPath string
BaseDir string
Title string
Pages []svglidePublishPage
}
type svglidePublishPage struct {
ID string
Index int
File string
SHA256 string
SourceBytes int
ContentBytes int
Content string
}
func prepareSVGlidePublishSpec(runtime *common.RuntimeContext, manifestPath string, overrideTitle string) (*svglidePublishSpec, error) {
if strings.TrimSpace(manifestPath) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest is required").WithParam("--manifest")
}
raw, err := readSVGlideManifestInput(runtime, manifestPath, "--manifest file not found: %s", manifestPath)
if err != nil {
return nil, err
}
var manifest svglideManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest invalid JSON: %v", err).WithParam("--manifest").WithCause(err)
}
if manifest.Version != svglideManifestVersion {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest version must be %s", svglideManifestVersion).WithParam("--manifest")
}
if manifest.Protocol != svglideBundleProtocol {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest protocol must be %s", svglideBundleProtocol).WithParam("--manifest")
}
if !manifest.PublishReady {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest publish_ready must be true").WithParam("--manifest")
}
if manifest.Size.Width != defaultPresentationWidth || manifest.Size.Height != defaultPresentationHeight {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest size must be %dx%d", defaultPresentationWidth, defaultPresentationHeight).WithParam("--manifest")
}
if len(manifest.Pages) == 0 || len(manifest.Pages) > maxSVGlideManifestPages {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest pages must contain 1 to %d entries", maxSVGlideManifestPages).WithParam("--manifest")
}
baseDir := filepath.Dir(filepath.Clean(manifestPath))
if baseDir == "." {
baseDir = ""
}
if err := validateSVGlideBundleReceipt(runtime, baseDir, manifest.Receipts); err != nil {
return nil, err
}
title := strings.TrimSpace(overrideTitle)
if title == "" {
title = effectiveTitle(strings.TrimSpace(manifest.Title))
}
seenIDs := map[string]bool{}
pages := make([]svglidePublishPage, 0, len(manifest.Pages))
for i, page := range manifest.Pages {
id := strings.TrimSpace(page.ID)
if id == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page id is required").WithParam("--manifest")
}
if seenIDs[id] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest duplicate page id: %s", id).WithParam("--manifest")
}
if page.Index != i+1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page index must be %d for page %s", i+1, id).WithParam("--manifest")
}
seenIDs[id] = true
joined, err := svglideManifestChildPath("page file", baseDir, page.File)
if err != nil {
return nil, err
}
content, err := readSVGlideManifestInput(runtime, joined, "--manifest page file not found: %s", page.File)
if err != nil {
return nil, err
}
if err := validateSVGlidePagePayload(page, content); err != nil {
return nil, err
}
pages = append(pages, svglidePublishPage{
ID: id,
Index: page.Index,
File: joined,
SHA256: page.SHA256,
SourceBytes: len(content),
ContentBytes: len(content),
Content: string(content),
})
}
return &svglidePublishSpec{
ManifestPath: manifestPath,
BaseDir: baseDir,
Title: title,
Pages: pages,
}, nil
}
type svglideValidateSVGDeckReceipt struct {
TotalErrors *int `json:"totalErrors"`
}
type svglideZeroErrorReceipt struct {
TotalErrors *int `json:"totalErrors"`
OK *bool `json:"ok"`
}
func validateSVGlideBundleReceipt(runtime *common.RuntimeContext, baseDir string, receipts svglideManifestReceipts) error {
receiptPath, err := svglideManifestChildPath("receipts.validate_svg_deck", baseDir, receipts.ValidateSVGDeck)
if err != nil {
return err
}
raw, err := readSVGlideManifestInput(runtime, receiptPath, "--manifest validate_svg_deck receipt not found: %s", receipts.ValidateSVGDeck)
if err != nil {
return err
}
var receipt svglideValidateSVGDeckReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest validate_svg_deck receipt invalid JSON: %v", err).WithParam("--manifest").WithCause(err)
}
if receipt.TotalErrors == nil || *receipt.TotalErrors != 0 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest validate_svg_deck receipt must have totalErrors=0").WithParam("--manifest")
}
required := []struct {
label string
rel string
}{
{"receipts.prompt_read_trace", receipts.PromptReadTrace},
{"receipts.asset_readiness", receipts.AssetReadiness},
{"receipts.typography_plan", receipts.TypographyPlan},
{"receipts.typography_plan_report", receipts.TypographyPlanReport},
{"receipts.visual_system", receipts.VisualSystem},
{"receipts.visual_system_report", receipts.VisualSystemReport},
{"receipts.color_rhythm", receipts.ColorRhythm},
{"receipts.frame_discipline", receipts.FrameDiscipline},
{"receipts.parser_contract", receipts.ParserContract},
{"receipts.readability_safety", receipts.ReadabilitySafety},
}
for _, item := range required {
label, rel := item.label, item.rel
if strings.TrimSpace(rel) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s is required", label).WithParam("--manifest")
}
joined, err := svglideManifestChildPath(label, baseDir, rel)
if err != nil {
return err
}
raw, err := readSVGlideManifestInput(runtime, joined, "--manifest %s receipt not found: %s", label, rel)
if err != nil {
return err
}
if label == "receipts.typography_plan_report" || label == "receipts.visual_system_report" || label == "receipts.color_rhythm" || label == "receipts.frame_discipline" || label == "receipts.parser_contract" || label == "receipts.readability_safety" {
var zero svglideZeroErrorReceipt
if err := json.Unmarshal(raw, &zero); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s receipt invalid JSON: %v", label, err).WithParam("--manifest").WithCause(err)
}
if zero.TotalErrors != nil && *zero.TotalErrors != 0 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have totalErrors=0", label).WithParam("--manifest")
}
if zero.OK != nil && !*zero.OK {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have ok=true", label).WithParam("--manifest")
}
}
}
return nil
}
func validateSVGlidePagePayload(page svglideManifestPage, content []byte) error {
wantSHA := strings.TrimSpace(page.SHA256)
if wantSHA == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page sha256 is required: %s", page.File).WithParam("--manifest")
}
if gotSHA := sha256Hex(content); gotSHA != wantSHA {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest page file sha256 mismatch: %s", page.File).WithParam("--manifest")
}
rootName, rootNamespace, err := xmlRootName(content)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file invalid XML: %s: %v", page.File, err).WithParam("--manifest").WithCause(err)
}
if detectForbiddenSVGlidePublishFormat(content, rootName) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file must be raw SVG, not Slides XML/SXSD/HTML/raster/data URL fallback: %s", page.File).WithParam("--manifest")
}
if rootName != "svg" || rootNamespace != svglideSVGNamespace {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file root must be <svg> in the SVG namespace: %s", page.File).WithParam("--manifest")
}
return nil
}
func xmlRootName(raw []byte) (string, string, error) {
decoder := xml.NewDecoder(bytes.NewReader(raw))
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing XML root element")
}
if err != nil {
return "", "", err
}
if start, ok := token.(xml.StartElement); ok {
return start.Name.Local, start.Name.Space, nil
}
}
}
func readSVGlideManifestInput(runtime *common.RuntimeContext, path string, message string, args ...interface{}) ([]byte, error) {
raw, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
if err == nil {
return raw, nil
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, message, args...).
WithParam("--manifest").
WithCause(err)
}
func detectForbiddenSVGlidePublishFormat(raw []byte, rootName string) bool {
trimmed := strings.TrimSpace(string(raw))
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.HasPrefix(lower, "data:image/") {
return true
}
if len(raw) >= 8 && bytes.Equal(raw[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
return true
}
if len(raw) >= 3 && raw[0] == 0xff && raw[1] == 0xd8 && raw[2] == 0xff {
return true
}
switch rootName {
case "slide", "presentation", "sxsd", "document", "html":
return true
default:
return false
}
}
func sha256Hex(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
func svglideManifestChildPath(label, baseDir, child string) (string, error) {
child = strings.TrimSpace(child)
if child == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s is required", label).WithParam("--manifest")
}
if filepath.IsAbs(child) || strings.HasPrefix(child, "/") || strings.HasPrefix(child, `\`) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s must be relative to the manifest directory: %s", label, child).WithParam("--manifest")
}
clean := filepath.Clean(child)
if clean == "." || hasParentDirSegment(clean) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s must be relative to the manifest directory and cannot contain '..': %s", label, child).WithParam("--manifest")
}
if baseDir == "" {
return clean, nil
}
return filepath.Join(baseDir, clean), nil
}
func hasParentDirSegment(path string) bool {
for _, part := range strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == '\\' }) {
if part == ".." {
return true
}
}
return false
}

View File

@@ -1,609 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"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"
"github.com/larksuite/cli/shortcuts/common"
)
const (
svglideValidateOnlyReceiptsJSON = `"validate_svg_deck":"receipts/validate_svg_deck.json"`
svglideRequiredReceiptsJSON = svglideValidateOnlyReceiptsJSON + `,"prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"`
)
func TestPrepareSVGlidePublishSpecReadsManifestAndPages(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
page := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="page-001" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`
mustWriteSVGlideTestFile(t, "pages/page-001.svg", page)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideRequiredNonValidateReceipts(t)
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": "Manifest Title",
"size": {"width": 960, "height": 540},
"publish_ready": true,
"published": false,
"pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, sha256Hex([]byte(page)))))
runtime, _ := newSVGlideTestRuntime(t)
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "")
if err != nil {
t.Fatalf("prepareSVGlidePublishSpec() error = %v", err)
}
if spec.Title != "Manifest Title" {
t.Fatalf("Title = %q, want Manifest Title", spec.Title)
}
if len(spec.Pages) != 1 {
t.Fatalf("Pages len = %d, want 1", len(spec.Pages))
}
if spec.Pages[0].ID != "page-001" || spec.Pages[0].File != "pages/page-001.svg" {
t.Fatalf("Page = %#v", spec.Pages[0])
}
if !strings.Contains(spec.Pages[0].Content, "<svg") {
t.Fatalf("page content was not read: %q", spec.Pages[0].Content)
}
}
func TestPrepareSVGlidePublishSpecAcceptsTwelvePageManifest(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideRequiredNonValidateReceipts(t)
pages := make([]string, 0, 12)
for i := 1; i <= 12; i++ {
id := fmt.Sprintf("page-%03d", i)
file := fmt.Sprintf("pages/%s.svg", id)
page := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="%s" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`, id)
mustWriteSVGlideTestFile(t, file, page)
pages = append(pages, fmt.Sprintf(`{"id":%q,"index":%d,"file":%q,"sha256":%q}`, id, i, file, sha256Hex([]byte(page))))
}
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": "Twelve Page Deck",
"size": {"width": 960, "height": 540},
"publish_ready": true,
"published": false,
"pages": [%s],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, strings.Join(pages, ","))))
runtime, _ := newSVGlideTestRuntime(t)
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "")
if err != nil {
t.Fatalf("prepareSVGlidePublishSpec() error = %v", err)
}
if len(spec.Pages) != 12 {
t.Fatalf("Pages len = %d, want 12", len(spec.Pages))
}
}
func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
page := `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`
mustWriteSVGlideTestFile(t, "page.svg", page)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideRequiredNonValidateReceipts(t)
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": "Manifest Title",
"size": {"width": 960, "height": 540},
"publish_ready": true,
"published": false,
"pages": [{"id": "p1", "index": 1, "file": "page.svg", "sha256": %q}],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, sha256Hex([]byte(page)))))
runtime, _ := newSVGlideTestRuntime(t)
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "CLI Title")
if err != nil {
t.Fatalf("prepareSVGlidePublishSpec() error = %v", err)
}
if spec.Title != "CLI Title" {
t.Fatalf("Title = %q, want CLI Title", spec.Title)
}
}
func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) {
tests := []struct {
name string
manifest string
pageFiles map[string]string
skipDefaultReceipts bool
wantMessage string
}{
{
name: "wrong version",
manifest: `{"version":"wrong","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest version must be svglide.manifest.v1",
},
{
name: "wrong size",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":1920,"height":1080},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest size must be 960x540",
},
{
name: "no pages",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[]}`,
pageFiles: map[string]string{"receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest pages must contain 1 to 50 entries",
},
{
name: "unsafe child path",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"../page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{"receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest page file must be relative to the manifest directory and cannot contain '..': ../page.svg",
},
{
name: "duplicate page id",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"a.svg","sha256":"<valid-sha-a>"},{"id":"p1","index":2,"file":"b.svg","sha256":"<valid-sha-b>"}]}`,
pageFiles: map[string]string{"a.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "b.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest duplicate page id: p1",
},
{
name: "wrong protocol",
manifest: `{"version":"svglide.manifest.v1","protocol":"wrong","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest protocol must be svg-slides.v1",
},
{
name: "not publish ready",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest publish_ready must be true",
},
{
name: "validate receipt has errors",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":1}`},
wantMessage: "--manifest validate_svg_deck receipt must have totalErrors=0",
},
{
name: "missing visual system receipt",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
},
skipDefaultReceipts: true,
wantMessage: "--manifest receipts.visual_system is required",
},
{
name: "missing parser contract receipt",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
},
skipDefaultReceipts: true,
wantMessage: "--manifest receipts.parser_contract is required",
},
{
name: "color rhythm receipt has errors",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/color_rhythm.json": `{"ok":false,"totalErrors":1}`,
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
"receipts/readability_safety.json": `{"ok":true,"totalErrors":0}`,
},
skipDefaultReceipts: true,
wantMessage: "--manifest receipts.color_rhythm receipt must have totalErrors=0",
},
{
name: "readability safety receipt has errors",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
"receipts/readability_safety.json": `{"ok":false,"totalErrors":1}`,
},
skipDefaultReceipts: true,
wantMessage: "--manifest receipts.readability_safety receipt must have totalErrors=0",
},
{
name: "typography report receipt has errors",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","typography_plan_report":"receipts/typography_plan_report.json","visual_system":"receipts/visual_system.json","visual_system_report":"receipts/visual_system_report.json","color_rhythm":"receipts/color_rhythm.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json","readability_safety":"receipts/readability_safety.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/typography_plan_report.json": `{"ok":false,"totalErrors":1}`,
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
"receipts/readability_safety.json": `{"ok":true,"totalErrors":0}`,
},
skipDefaultReceipts: true,
wantMessage: "--manifest receipts.typography_plan_report receipt must have totalErrors=0",
},
{
name: "sha mismatch",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"bad"}]}`,
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest page file sha256 mismatch: page.svg",
},
{
name: "non svg root",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{"page.svg": `<slide xmlns="http://www.larkoffice.com/sml/2.0"></slide>`, "receipts/validate_svg_deck.json": `{"totalErrors":0}`},
wantMessage: "--manifest page file must be raw SVG",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
for path, body := range tt.pageFiles {
mustWriteSVGlideTestFile(t, path, body)
}
if !tt.skipDefaultReceipts {
mustWriteSVGlideRequiredNonValidateReceipts(t)
tt.manifest = withSVGlideRequiredReceipts(tt.manifest)
}
if body, ok := tt.pageFiles["page.svg"]; ok {
tt.manifest = strings.ReplaceAll(tt.manifest, "<valid-sha>", sha256Hex([]byte(body)))
}
if body, ok := tt.pageFiles["a.svg"]; ok {
tt.manifest = strings.ReplaceAll(tt.manifest, "<valid-sha-a>", sha256Hex([]byte(body)))
}
if body, ok := tt.pageFiles["b.svg"]; ok {
tt.manifest = strings.ReplaceAll(tt.manifest, "<valid-sha-b>", sha256Hex([]byte(body)))
}
mustWriteSVGlideTestFile(t, "manifest.json", tt.manifest)
runtime, _ := newSVGlideTestRuntime(t)
_, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "")
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("err = %v, want message containing %q", err, tt.wantMessage)
}
})
}
}
func mustWriteSVGlideTestFile(t *testing.T, path string, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func withSVGlideRequiredReceipts(manifest string) string {
manifest = strings.ReplaceAll(manifest, `"receipts":{`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`)
manifest = strings.ReplaceAll(manifest, `"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`)
manifest = strings.ReplaceAll(manifest, `"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`)
return strings.ReplaceAll(manifest, `"receipts": {`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`)
}
func mustWriteSVGlideRequiredNonValidateReceipts(t *testing.T) {
t.Helper()
for path, body := range map[string]string{
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/typography_plan_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/visual_system.json": `{"version":"svg-slides.visual-system.v1"}`,
"receipts/visual_system_report.json": `{"ok":true,"totalErrors":0}`,
"receipts/color_rhythm.json": `{"ok":true,"totalErrors":0}`,
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
"receipts/readability_safety.json": `{"ok":true,"totalErrors":0}`,
} {
if _, err := os.Stat(path); err == nil {
continue
}
mustWriteSVGlideTestFile(t, path, body)
}
}
func TestPlanSVGlideImageRewritesResolvesAndDedupes(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
mustWriteSVGlideTestFile(t, "assets/images/a.png", "png")
runtime, _ := newSVGlideTestRuntime(t)
spec := &svglidePublishSpec{
BaseDir: "",
Pages: []svglidePublishPage{
{ID: "p1", File: "slides/page-001.svg", Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
{ID: "p2", File: "slides/page-002.svg", Content: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="../assets/images/a.png"/></svg>`},
},
}
plan, err := planSVGlideImageRewrites(runtime, spec)
if err != nil {
t.Fatalf("planSVGlideImageRewrites() error = %v", err)
}
if len(plan.Refs) != 2 {
t.Fatalf("Refs len = %d, want 2", len(plan.Refs))
}
if len(plan.UniqueFiles) != 1 {
t.Fatalf("UniqueFiles len = %d, want 1", len(plan.UniqueFiles))
}
if got := filepath.ToSlash(plan.UniqueFiles[0].FilePath); got != "assets/images/a.png" {
t.Fatalf("Unique file = %q, want assets/images/a.png", got)
}
if plan.Refs[0].Ref.AttrName != "href" || plan.Refs[1].Ref.AttrName != "xlink:href" {
t.Fatalf("attr names = %q/%q", plan.Refs[0].Ref.AttrName, plan.Refs[1].Ref.AttrName)
}
}
func TestPlanSVGlideImageRewritesRejectsUnsupportedHrefs(t *testing.T) {
tests := []struct {
name string
href string
}{
{name: "remote", href: "https://example.com/a.png"},
{name: "data url", href: "data:image/png;base64,xxx"},
{name: "fragment", href: "#asset"},
{name: "at path", href: "@assets/a.png"},
{name: "missing local", href: "../assets/missing.png"},
{name: "outside root", href: "../../outside.png"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
runtime, _ := newSVGlideTestRuntime(t)
spec := &svglidePublishSpec{
BaseDir: "",
Pages: []svglidePublishPage{{
ID: "p1",
File: "slides/page-001.svg",
Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="` + tt.href + `"/></svg>`,
}},
}
_, err := planSVGlideImageRewrites(runtime, spec)
if err == nil {
t.Fatal("expected validation error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
}
if validationErr.Param != "--manifest" {
t.Fatalf("Param = %q, want --manifest", validationErr.Param)
}
})
}
}
func TestUploadAndRewriteSVGlideImagesUploadsOnceAndRewrites(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
mustWriteSVGlideTestFile(t, "assets/images/a.png", "png")
runtime, reg := newSVGlideTestRuntime(t)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_tok_1"},
},
}
reg.Register(uploadStub)
spec := &svglidePublishSpec{
BaseDir: "",
Pages: []svglidePublishPage{
{ID: "p1", File: "slides/page-001.svg", SHA256: "sha1", SourceBytes: 100, ContentBytes: 100, Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
{ID: "p2", File: "slides/page-002.svg", SHA256: "sha2", SourceBytes: 100, ContentBytes: 100, Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
},
}
plan, err := planSVGlideImageRewrites(runtime, spec)
if err != nil {
t.Fatalf("planSVGlideImageRewrites() error = %v", err)
}
pages, result, err := uploadAndRewriteSVGlideImages(runtime, spec, plan, "pres_sg")
if err != nil {
t.Fatalf("uploadAndRewriteSVGlideImages() error = %v", err)
}
if result.UploadedCount != 1 {
t.Fatalf("UploadedCount = %d, want 1", result.UploadedCount)
}
if len(result.Entries) != 2 {
t.Fatalf("Entries len = %d, want 2", len(result.Entries))
}
for _, page := range pages {
if strings.Contains(page.Content, "../assets/images/a.png") {
t.Fatalf("page content still contains local href: %s", page.Content)
}
if !strings.Contains(page.Content, `href="file_tok_1"`) {
t.Fatalf("page content missing token href: %s", page.Content)
}
if page.SHA256 == "" || page.SourceBytes != 100 || page.ContentBytes == 100 {
t.Fatalf("page hash/source/submitted bytes not preserved/updated: %#v", page)
}
}
body := decodeMultipartBody(t, uploadStub)
if got := body.Fields["parent_type"]; got != slidesMediaParentType {
t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType)
}
if got := body.Fields["parent_node"]; got != "pres_sg" {
t.Fatalf("parent_node = %q, want pres_sg", got)
}
if got := len(body.Files["file"]); got != 3 {
t.Fatalf("uploaded file size = %d, want 3", got)
}
reg.Verify(t)
}
func TestSlidesCreateSVGlideExecute(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
page := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="page-001" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`
mustWriteSVGlideBundle(t, "SVGlide Deck", page)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_sg",
"revision_id": 1,
"url": "https://tenant.example.com/slides/pres_sg",
},
},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_sg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
}
reg.Register(slideStub)
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
"+create-svglide",
"--manifest", "manifest.json",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_sg" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
if data["slides_added"] != float64(1) {
t.Fatalf("slides_added = %v, want 1", data["slides_added"])
}
if data["svglide_manifest_version"] != svglideManifestVersion {
t.Fatalf("svglide_manifest_version = %v", data["svglide_manifest_version"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
slide := body["slide"].(map[string]interface{})
if !strings.Contains(slide["content"].(string), "<svg") {
t.Fatalf("slide content did not contain SVGlide payload: %#v", slide["content"])
}
}
func TestSlidesCreateSVGlideDryRun(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
page := `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"><rect/></svg>`
mustWriteSVGlideBundle(t, "SVGlide Deck", page)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
"+create-svglide",
"--manifest", "manifest.json",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Create SVGlide presentation", "Add SVGlide page 1", "source_sha256", "submitted_sha256", "/open-apis/slides_ai/v1/xml_presentations", "/slide"} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run missing %q: %s", want, out)
}
}
if strings.Contains(out, "<svg") {
t.Fatalf("dry-run must summarize SVG content instead of printing full payload: %s", out)
}
}
func mustWriteSVGlideBundle(t *testing.T, title string, page string) {
t.Helper()
mustWriteSVGlideTestFile(t, "pages/page-001.svg", page)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideRequiredNonValidateReceipts(t)
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": %q,
"size": {"width": 960, "height": 540},
"publish_ready": true,
"published": false,
"pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, title, sha256Hex([]byte(page)))))
}
func newSVGlideTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "svglide-test",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create-svglide"}, cfg, f, core.AsUser)
return runtime, reg
}

View File

@@ -5,6 +5,7 @@ package vc
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -15,6 +16,7 @@ import (
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -25,6 +27,9 @@ const (
minVCMeetingEventsPageSize = 20
maxVCMeetingEventsPageSize = 100
maxVCMeetingEventsPages = 200
leaveReasonUserLeft = 1
leaveReasonMeetingEnded = 2
leaveReasonKicked = 3
)
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
@@ -41,11 +46,11 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
return ts, nil
}
// VCMeetingEvents lists bot meeting events for a meeting.
// VCMeetingEvents lists meeting events for a meeting.
var VCMeetingEvents = common.Shortcut{
Service: "vc",
Command: "+meeting-events",
Description: "List bot meeting events by meeting ID",
Description: "List meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
@@ -99,20 +104,28 @@ var VCMeetingEvents = common.Shortcut{
return err
}
events = compactMeetingEvents(events)
outData := map[string]interface{}{
"events": events,
"has_more": data["has_more"],
"page_token": data["page_token"],
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
outData := buildMeetingEventsOutput(data, events, identity, identityWarning)
metadata := map[string]interface{}{
"row_type": "metadata",
"meeting": outData.Meeting,
"identity": outData.Identity,
"has_more": outData.HasMore,
"page_token": outData.PageToken,
}
if len(outData.Warnings) > 0 {
metadata["warnings"] = outData.Warnings
}
ndjsonData := meetingEventsEventRows(outData.Events, metadata)
timeline := buildMeetingEventTimeline(events)
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
if len(timeline.entries) == 0 {
fmt.Fprintln(w, "No meeting events.")
return
}
io.WriteString(w, renderMeetingEventsPretty(timeline))
})
if runtime.Format == "ndjson" {
runtime.OutFormat(ndjsonData, &output.Meta{Count: len(events)}, func(w io.Writer) {})
} else {
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
renderMeetingEventsCompactPretty(w, outData, timeline)
})
}
if runtime.Format == "pretty" && pageToken != "" {
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
if hasMore {
@@ -123,6 +136,400 @@ var VCMeetingEvents = common.Shortcut{
},
}
type meetingEventsOutput struct {
Meeting meetingEventsMeeting `json:"meeting"`
Identity meetingEventsIdentity `json:"identity"`
Events []meetingEventsEvent `json:"events"`
Warnings []string `json:"warnings,omitempty"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token,omitempty"`
}
type meetingEventsMeeting struct {
ID string `json:"id,omitempty"`
Topic string `json:"topic,omitempty"`
MeetingNo string `json:"meeting_no,omitempty"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Status string `json:"status"`
}
type meetingEventsIdentity struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
ParticipantType string `json:"participant_type,omitempty"`
Role string `json:"role,omitempty"`
Label string `json:"label,omitempty"`
}
type meetingEventsEvent struct {
EventID string `json:"event_id,omitempty"`
EventType string `json:"event_type,omitempty"`
EventTime string `json:"event_time,omitempty"`
Actors []meetingEventsIdentity `json:"actors,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
}
type meetingEventsEndSignal struct {
Ended bool
EndTime time.Time
HasEndTime bool
}
func buildMeetingEventsOutput(data map[string]interface{}, events []interface{}, identity meetingEventsIdentity, warnings ...string) meetingEventsOutput {
output := meetingEventsOutput{
Meeting: meetingEventsMeetingFromPayload(nil),
Identity: identity,
HasMore: common.GetBool(data, "has_more"),
PageToken: common.GetString(data, "page_token"),
}
for _, warning := range warnings {
if warning = strings.TrimSpace(warning); warning != "" {
output.Warnings = append(output.Warnings, warning)
}
}
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
continue
}
payload := common.GetMap(event, "payload")
if meeting := common.GetMap(payload, "meeting"); meeting != nil {
output.Meeting = meetingEventsMeetingFromPayload(meeting)
}
output.Events = append(output.Events, meetingEventsEventFromPayload(event, output.Identity))
}
applyMeetingEventsEndSignal(&output.Meeting, meetingEventsEndSignalFromEvents(events))
return output
}
func meetingEventsCurrentIdentity(runtime *common.RuntimeContext) (meetingEventsIdentity, string) {
if runtime.As() == core.AsBot {
botInfo, err := runtime.BotInfo()
if err != nil {
return meetingEventsBotIdentity(nil), fmt.Sprintf("identity unavailable: %v", err)
}
return meetingEventsBotIdentity(botInfo), ""
}
userOpenID := strings.TrimSpace(runtime.UserOpenId())
identity := meetingEventsIdentity{
ID: userOpenID,
Name: strings.TrimSpace(runtime.Config.UserName),
ParticipantType: "human",
}
identity.Label = identityLabel(identity)
if userOpenID == "" {
return identity, "identity unavailable: current user open_id is unavailable"
}
return identity, ""
}
func meetingEventsBotIdentity(botInfo *common.BotInfo) meetingEventsIdentity {
if botInfo == nil {
return meetingEventsIdentity{ParticipantType: "bot", Label: "bot"}
}
identity := meetingEventsIdentity{
ID: botInfo.OpenID,
Name: botInfo.AppName,
ParticipantType: "bot",
}
identity.Label = identityLabel(identity)
return identity
}
func meetingEventsMeetingFromPayload(meeting map[string]interface{}) meetingEventsMeeting {
out := meetingEventsMeeting{
ID: common.GetString(meeting, "id"),
Topic: common.GetString(meeting, "topic"),
MeetingNo: common.GetString(meeting, "meeting_no"),
StartTime: meetingEventsTimeString(common.GetString(meeting, "start_time")),
EndTime: meetingEventsTimeString(common.GetString(meeting, "end_time")),
Status: "unknown",
}
start, hasStart := parseFlexibleTime(out.StartTime)
end, hasEnd := parseFlexibleTime(out.EndTime)
if hasStart && !hasEnd {
out.Status = "ongoing"
}
if hasStart && hasEnd {
if end.After(start) {
out.Status = "ended"
} else {
out.Status = "ongoing"
out.EndTime = ""
}
}
return out
}
func applyMeetingEventsEndSignal(meeting *meetingEventsMeeting, signal meetingEventsEndSignal) {
if meeting == nil || !signal.Ended {
return
}
meeting.Status = "ended"
if signal.HasEndTime {
meeting.EndTime = signal.EndTime.UTC().Format(time.RFC3339)
}
}
func meetingEventsEndSignalFromEvents(events []interface{}) meetingEventsEndSignal {
var signal meetingEventsEndSignal
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil || meetingEventType(event) != "participant_left" {
continue
}
payload := common.GetMap(event, "payload")
if payload == nil {
continue
}
fallbackTime, fallbackOK := parseFlexibleTime(common.GetString(event, "event_time"))
for _, rawItem := range common.GetSlice(payload, "participant_left_items") {
item, _ := rawItem.(map[string]interface{})
if item == nil || int(common.GetFloat(item, "leave_reason")) != leaveReasonMeetingEnded {
continue
}
signal.Ended = true
endTime, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
if !ok {
endTime, ok = fallbackTime, fallbackOK
}
if ok && (!signal.HasEndTime || endTime.After(signal.EndTime)) {
signal.EndTime = endTime
signal.HasEndTime = true
}
}
}
return signal
}
func meetingEventsEventFromPayload(event map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsEvent {
payload := common.GetMap(event, "payload")
out := meetingEventsEvent{
EventID: common.GetString(event, "event_id"),
EventType: meetingEventType(event),
EventTime: meetingEventsTimeString(common.GetString(event, "event_time")),
Payload: payload,
}
out.Actors = eventActors(out.EventType, payload, selfIdentity)
return out
}
func eventActors(eventType string, payload map[string]interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
var actors []meetingEventsIdentity
addFromItems := func(key, participantKey string) {
for _, raw := range common.GetSlice(payload, key) {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
if participant := common.GetMap(item, participantKey); participant != nil {
actors = append(actors, meetingEventsIdentityFromParticipant(participant, selfIdentity))
}
}
}
switch eventType {
case "participant_joined":
addFromItems("participant_joined_items", "participant")
case "participant_left":
addFromItems("participant_left_items", "participant")
case "transcript_received":
addFromItems("transcript_received_items", "speaker")
case "chat_received":
addFromItems("chat_received_items", "operator")
case "magic_share_started":
addFromItems("magic_share_started_items", "operator")
case "magic_share_ended":
addFromItems("magic_share_ended_items", "operator")
}
return actors
}
func meetingEventsIdentityFromParticipant(participant map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsIdentity {
identity := meetingEventsIdentity{
ID: common.GetString(participant, "id"),
Name: common.GetString(participant, "user_name"),
ParticipantType: meetingEventsParticipantType(participant),
Role: meetingEventsParticipantRole(participant),
}
if identity.ID != "" && selfIdentity.ID != "" && identity.ID == selfIdentity.ID {
if selfIdentity.ParticipantType == "bot" && (identity.ParticipantType == "" || identity.ParticipantType == "human") {
identity.ParticipantType = "bot"
}
if selfIdentity.ParticipantType == "bot" && (identity.Role == "" || identity.Role == "participant") {
identity.Role = "bot"
}
}
if identity.ParticipantType == "" {
identity.ParticipantType = "human"
}
if identity.Role == "" {
identity.Role = "participant"
}
identity.Label = identityLabel(identity)
return identity
}
func meetingEventsParticipantType(participant map[string]interface{}) string {
if raw := meetingEventsParticipantTypeFromParticipantType(fieldValueString(participant, "participant_type")); raw != "" {
return raw
}
return meetingEventsParticipantTypeFromUserType(fieldValueString(participant, "user_type"))
}
func meetingEventsParticipantTypeFromParticipantType(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "user", "human":
return "human"
case "2", "bot", "app":
return "bot"
case "":
return ""
default:
return "unknown"
}
}
func meetingEventsParticipantRole(participant map[string]interface{}) string {
if raw := meetingEventsRoleFromParticipantRole(fieldValueString(participant, "role")); raw != "" {
return raw
}
return meetingEventsRoleFromEventUserRole(fieldValueString(participant, "user_role"))
}
func meetingEventsParticipantTypeFromUserType(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "user", "human":
return "human"
case "2", "10", "bot", "app":
return "bot"
case "":
return ""
default:
return "unknown"
}
}
func meetingEventsRoleFromParticipantRole(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "host":
return "host"
case "2", "co_host", "cohost":
return "co_host"
case "3", "participant", "attendee":
return "participant"
case "4", "bot", "app":
return "bot"
case "":
return ""
default:
return raw
}
}
func meetingEventsRoleFromEventUserRole(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "participant", "attendee":
return "participant"
case "2", "host":
return "host"
case "4", "bot", "app":
return "bot"
case "", "0":
return ""
default:
return raw
}
}
func fieldValueString(values map[string]interface{}, key string) string {
if values == nil {
return ""
}
switch value := values[key].(type) {
case string:
return value
case int:
return strconv.Itoa(value)
case int64:
return strconv.FormatInt(value, 10)
case float64:
return strconv.FormatInt(int64(value), 10)
case json.Number:
return value.String()
default:
return ""
}
}
func identityLabel(identity meetingEventsIdentity) string {
name := identity.Name
if name == "" {
name = identity.ID
}
if name == "" {
name = "unknown"
}
var tags []string
if identity.ParticipantType != "" {
tags = append(tags, identity.ParticipantType)
}
if identity.Role != "" && identity.Role != identity.ParticipantType {
tags = append(tags, identity.Role)
}
if len(tags) == 0 {
return name
}
return fmt.Sprintf("%s [%s]", name, strings.Join(tags, ","))
}
func meetingEventsTimeString(raw string) string {
if parsed, ok := parseFlexibleTime(raw); ok {
return parsed.UTC().Format(time.RFC3339)
}
return strings.TrimSpace(raw)
}
func meetingEventsEventRows(events []meetingEventsEvent, metadata map[string]interface{}) []interface{} {
rows := make([]interface{}, 0, len(events)+1)
for _, event := range events {
row := meetingEventsEventRow(event)
rows = append(rows, row)
}
if metadata != nil {
rows = append(rows, metadata)
}
return rows
}
func meetingEventsEventRow(event meetingEventsEvent) map[string]interface{} {
raw, err := json.Marshal(event)
if err != nil {
return map[string]interface{}{"row_type": "event"}
}
var row map[string]interface{}
if err := json.Unmarshal(raw, &row); err != nil {
return map[string]interface{}{"row_type": "event"}
}
row["row_type"] = "event"
return row
}
func renderMeetingEventsCompactPretty(w io.Writer, data meetingEventsOutput, timeline meetingTimeline) {
if data.Identity.Label != "" {
fmt.Fprintf(w, "当前身份:%s\n", escapePrettyText(data.Identity.Label))
}
if len(timeline.entries) == 0 {
fmt.Fprintln(w, "No meeting events.")
return
}
io.WriteString(w, renderMeetingEventsPretty(timeline))
}
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
if runtime.Bool("page-all") {
return maxVCMeetingEventsPageSize, nil
@@ -323,7 +730,6 @@ type meetingTimelineEntry struct {
when time.Time
hasWhen bool
sequence int
group int
subject string
description string
details []string
@@ -332,7 +738,6 @@ type meetingTimelineEntry struct {
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
timeline := meetingTimeline{}
var sequence int
var group int
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
@@ -345,11 +750,11 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
}
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
for _, entry := range buildTimelineEntriesForEvent(event, &sequence) {
timeline.entries = append(timeline.entries, entry)
}
group++
}
applyMeetingTimelineEndSignal(&timeline, meetingEventsEndSignalFromEvents(events))
sort.SliceStable(timeline.entries, func(i, j int) bool {
left := timeline.entries[i]
right := timeline.entries[j]
@@ -370,6 +775,24 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
return timeline
}
func applyMeetingTimelineEndSignal(timeline *meetingTimeline, signal meetingEventsEndSignal) {
if timeline == nil || !signal.Ended {
return
}
if signal.HasEndTime {
if !timeline.hasStart || signal.EndTime.After(timeline.startTime) {
timeline.endTime = signal.EndTime
timeline.hasEnd = true
return
}
timeline.hasEnd = false
return
}
if timeline.hasStart && timeline.hasEnd && !timeline.endTime.After(timeline.startTime) {
timeline.hasEnd = false
}
}
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
if timeline == nil || meeting == nil {
return
@@ -391,7 +814,7 @@ func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interfa
}
}
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int) []meetingTimelineEntry {
payload := common.GetMap(event, "payload")
if payload == nil {
return nil
@@ -400,26 +823,26 @@ func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, g
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
switch eventType {
case "participant_joined":
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence)
case "participant_left":
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence)
case "transcript_received":
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
return transcriptEntries(payload, eventTime, eventTimeOK, sequence)
case "chat_received":
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
return chatEntries(payload, eventTime, eventTimeOK, sequence)
case "magic_share_started":
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence)
case "magic_share_ended":
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence)
default:
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
}
}
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_joined_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -432,15 +855,15 @@ func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "加入了会议", nil))
}
return entries
}
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_left_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -453,15 +876,15 @@ func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Ti
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, leaveAction(item), nil))
}
return entries
}
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "transcript_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -479,15 +902,15 @@ func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, f
if text != "" {
description = text
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
}
return entries
}
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "chat_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -507,15 +930,15 @@ func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbac
} else {
description = fmt.Sprintf("[%s] %s", typeLabel, description)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
}
return entries
}
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_started_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -538,15 +961,15 @@ func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.
if url != "" {
details = append(details, "URL: "+url)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, details))
}
return entries
}
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_ended_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -559,17 +982,16 @@ func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Ti
if subject == "" {
subject = "未知用户"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "结束共享", nil))
}
return entries
}
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string, details []string) meetingTimelineEntry {
entry := meetingTimelineEntry{
when: when,
hasWhen: hasWhen,
sequence: *sequence,
group: group,
subject: subject,
description: description,
details: details,
@@ -713,9 +1135,9 @@ func needsColon(description string) bool {
func leaveAction(item map[string]interface{}) string {
switch int(common.GetFloat(item, "leave_reason")) {
case 2:
case leaveReasonMeetingEnded:
return "因会议结束离开了会议"
case 3:
case leaveReasonKicked:
return "被移出了会议"
default:
return "离开了会议"

View File

@@ -5,6 +5,7 @@ package vc
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
@@ -54,6 +55,33 @@ func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *ht
}
}
func botInfoStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "bot_001",
"app_name": "Demo Bot",
},
},
}
}
func botInfoErrorStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Status: 500,
Body: map[string]interface{}{
"code": 99991663,
"msg": "bot info unavailable",
},
}
}
func participantJoinedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-1",
@@ -73,6 +101,8 @@ func participantJoinedEvent() map[string]interface{} {
"participant": map[string]interface{}{
"id": "bot_001",
"user_name": "Demo Bot",
"user_type": 2,
"user_role": 4,
},
"join_time": "2026-04-17T08:00:00Z",
},
@@ -90,6 +120,36 @@ func participantJoinedEventOngoing() map[string]interface{} {
return event
}
func participantLeftEventWithReason(leaveReason int) map[string]interface{} {
return map[string]interface{}{
"event_id": "event-left",
"event_type": "participant_left",
"event_time": "2026-04-17T07:18:50Z",
"payload": map[string]interface{}{
"activity_event_type": "participant_left",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776410100",
"end_time": "1776410100",
},
"participant_left_items": []interface{}{
map[string]interface{}{
"participant": map[string]interface{}{
"id": "bot_001",
"user_name": "Demo Bot",
"user_type": 2,
"user_role": 4,
},
"leave_time": "1776410330000",
"leave_reason": leaveReason,
},
},
},
}
}
func chatReceivedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-2",
@@ -112,7 +172,7 @@ func chatReceivedEvent() map[string]interface{} {
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 3,
"message_type": 1,
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
@@ -140,7 +200,7 @@ func multiChatReceivedEvent() map[string]interface{} {
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "第一条\n第二行",
"message_type": 3,
"message_type": 1,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
@@ -149,6 +209,44 @@ func multiChatReceivedEvent() map[string]interface{} {
},
map[string]interface{}{
"content": "第二条",
"message_type": 1,
"send_time": "1776408062000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
},
},
}
}
func mixedChatAndReactionEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-reaction",
"event_type": "chat_received",
"event_time": "2026-04-17T08:05:00Z",
"payload": map[string]interface{}{
"activity_event_type": "chat_received",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 1,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
map[string]interface{}{
"content": "OK",
"message_type": 3,
"send_time": "1776408062000",
"operator": map[string]interface{}{
@@ -414,7 +512,7 @@ func TestMeetingEvents_DryRun(t *testing.T) {
"--start", "1710000000",
"--end", "1710003600",
"--dry-run",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -442,7 +540,7 @@ func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
"--meeting-id", "7628568141510692381",
"--page-all",
"--dry-run",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -457,24 +555,39 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--page-all",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v: %s", err, stdout.String())
}
events := common.GetSlice(common.GetMap(envelope, "data"), "events")
if got := len(events); got != 2 {
t.Fatalf("events len = %d, want 2: %s", got, stdout.String())
}
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if _, ok := event["summary"]; ok {
t.Fatalf("event should not expose summary: %s", stdout.String())
}
if _, ok := event["raw"]; ok {
t.Fatalf("event should not expose raw: %s", stdout.String())
}
}
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
}
if !strings.Contains(out, `"has_more":false`) {
t.Fatalf("expected final has_more=false: %s", stdout.String())
}
@@ -483,6 +596,80 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"identity":{"id":"bot_001","name":"DemoBot","participant_type":"bot","label":"DemoBot[bot]"}`,
`"role":"bot"`,
`"event_type":"participant_joined"`,
`"actors":[`,
`"start_time":"2026-04-17T06:35:00Z"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"events":[`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
for _, unwanted := range []string{
`"current_participants":`,
`"is_self":`,
`"summary":`,
`"raw":`,
} {
if strings.Contains(out, unwanted) {
t.Fatalf("json output should not contain %q: %s", unwanted, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
reg.Register(botInfoErrorStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"event_type":"participant_joined"`,
`"identity":{"participant_type":"bot","label":"bot"}`,
`"warnings":[`,
`identityunavailable`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_UserIdentitySkipsBotInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
@@ -498,26 +685,205 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"identity":{"id":"ou_testuser","participant_type":"human","label":"ou_testuser[human]"}`,
`"event_type":"participant_joined"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"events":[`,
`"has_more":false`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
t.Fatalf("user json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_OngoingMeetingOmitsEndTime(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
t.Fatalf("invalid json output: %v\n%s", err, stdout.String())
}
data := common.GetMap(envelope, "data")
meeting := common.GetMap(data, "meeting")
if got := common.GetString(meeting, "status"); got != "ongoing" {
t.Fatalf("meeting status = %q, want ongoing: %s", got, stdout.String())
}
if _, ok := meeting["end_time"]; ok {
t.Fatalf("ongoing meeting should not expose dirty top-level end_time: %s", stdout.String())
}
}
func TestBuildMeetingEventsOutput_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
participantLeftEventWithReason(leaveReasonMeetingEnded),
}, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "ended" {
t.Fatalf("meeting status = %q, want ended", got)
}
if got := out.Meeting.EndTime; got != "2026-04-17T07:18:50Z" {
t.Fatalf("meeting end_time = %q, want leave time", got)
}
}
func TestBuildMeetingEventsOutput_NormalLeaveReasonDoesNotEndMeeting(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
participantLeftEventWithReason(leaveReasonUserLeft),
}, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "ongoing" {
t.Fatalf("meeting status = %q, want ongoing", got)
}
if got := out.Meeting.EndTime; got != "" {
t.Fatalf("meeting end_time = %q, want empty", got)
}
}
func TestRenderMeetingEventsPretty_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
timeline := buildMeetingEventTimeline([]interface{}{
participantLeftEventWithReason(leaveReasonMeetingEnded),
})
got := renderMeetingEventsPretty(timeline)
if strings.Contains(got, "进行中") {
t.Fatalf("pretty output should not show ongoing for meeting-ended leave reason: %s", got)
}
if !strings.Contains(got, "会议时间2026-04-17 15:15:00 - 2026-04-17 15:18:50") {
t.Fatalf("pretty output missing derived meeting end window: %s", got)
}
}
func TestBuildMeetingEventsOutput_UsesLatestMeetingSnapshot(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
participantJoinedEventOngoing(),
participantJoinedEvent(),
}, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "ended" {
t.Fatalf("meeting status = %q, want ended", got)
}
if got := out.Meeting.EndTime; got != "2026-04-17T07:35:00Z" {
t.Fatalf("meeting end_time = %q, want latest ended snapshot", got)
}
if got := len(out.Events); got != 2 {
t.Fatalf("events len = %d, want 2", got)
}
}
func TestBuildMeetingEventsOutput_EmptyEventsHasUnknownMeetingStatus(t *testing.T) {
out := buildMeetingEventsOutput(map[string]interface{}{}, nil, meetingEventsIdentity{})
if got := out.Meeting.Status; got != "unknown" {
t.Fatalf("meeting status = %q, want unknown", got)
}
}
func TestMeetingEventsMeetingFromPayload_StartOnlyIsOngoing(t *testing.T) {
got := meetingEventsMeetingFromPayload(map[string]interface{}{
"id": "m1",
"start_time": "1776410100",
})
if got.Status != "ongoing" {
t.Fatalf("meeting status = %q, want ongoing", got.Status)
}
if got.StartTime != "2026-04-17T07:15:00Z" {
t.Fatalf("meeting start_time = %q, want normalized RFC3339", got.StartTime)
}
if got.EndTime != "" {
t.Fatalf("meeting end_time = %q, want empty", got.EndTime)
}
}
func TestMeetingEvents_ExecuteNDJSONIncludesMetadataRow(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "ndjson",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 2 {
t.Fatalf("ndjson lines = %d, want 2: %s", len(lines), stdout.String())
}
if !strings.Contains(lines[0], `"row_type":"event"`) || !strings.Contains(lines[0], `"event_type":"participant_joined"`) {
t.Fatalf("first ndjson row should be event: %s", lines[0])
}
for _, unwanted := range []string{
`"summary":`,
`"raw":`,
} {
if strings.Contains(lines[0], unwanted) {
t.Fatalf("event ndjson row should not contain %q: %s", unwanted, lines[0])
}
}
for _, want := range []string{
`"row_type":"metadata"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"identity":`,
} {
if !strings.Contains(lines[1], want) {
t.Fatalf("metadata ndjson row missing %q: %s", want, lines[1])
}
}
}
func TestMeetingEventsEventRows_OmitsEmptyEventFields(t *testing.T) {
rows := meetingEventsEventRows([]meetingEventsEvent{
{EventType: "unknown_event"},
}, nil)
if len(rows) != 1 {
t.Fatalf("rows len = %d, want 1", len(rows))
}
row, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("row type = %T, want map", rows[0])
}
for _, unwanted := range []string{"event_id", "event_time", "actors", "payload"} {
if _, exists := row[unwanted]; exists {
t.Fatalf("row should omit %q when empty: %#v", unwanted, row)
}
}
if got := row["row_type"]; got != "event" {
t.Fatalf("row_type = %v, want event", got)
}
if got := row["event_type"]; got != "unknown_event" {
t.Fatalf("event_type = %v, want unknown_event", got)
}
}
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -536,20 +902,54 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
t.Fatalf("json output should not contain %q: %s", unwanted, out)
}
}
if !strings.Contains(out, `"message_type": 3`) {
if !strings.Contains(out, `"message_type": 1`) {
t.Fatalf("json output should keep numeric fields: %s", out)
}
}
func TestMeetingEvents_ExecuteJSON_PreservesReactionItems(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{mixedChatAndReactionEvent()}, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"event_type":"chat_received"`,
`"chat_received_items":[`,
`"content":"OK"`,
`"message_type":3`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
if strings.Contains(out, `"im_post"`) {
t.Fatalf("json output should not include IM post payload: %s", stdout.String())
}
}
func TestMeetingEvents_ExecutePretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -558,11 +958,12 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"当前身份Demo Bot [bot]",
"会议主题:项目例会",
"会议时间2026-04-17 15:15:00进行中",
"Demo Bot(bot_001) 加入了会议",
"Alice(u1): [reaction] 第一条\\n第二行",
"Alice(u1): [reaction] 第二条",
"Alice(u1): [text] 第一条\\n第二行",
"Alice(u1): [text] 第二条",
"Bob(u2) 开始共享「共享文档」",
"URL: https://example.com/doc",
"page_token: 1710000000000000000",
@@ -582,12 +983,13 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -606,12 +1008,13 @@ func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T)
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub(nil, false, ""))
reg.Register(botInfoStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -850,9 +1253,9 @@ func TestLeaveAction(t *testing.T) {
item map[string]interface{}
want string
}{
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
{name: "meeting ended", item: map[string]interface{}{"leave_reason": leaveReasonMeetingEnded}, want: "因会议结束离开了会议"},
{name: "kicked", item: map[string]interface{}{"leave_reason": leaveReasonKicked}, want: "被移出了会议"},
{name: "default", item: map[string]interface{}{"leave_reason": leaveReasonUserLeft}, want: "离开了会议"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -884,6 +1287,70 @@ func TestMeetingEventUserWithID(t *testing.T) {
}
}
func TestMeetingEventsIdentityFromParticipant_UsesContractFields(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"user_type": 1,
"user_role": 2,
}, meetingEventsIdentity{})
if got.ParticipantType != "human" || got.Role != "host" {
t.Fatalf("identity = %#v, want participant_type=human role=host", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UserRoleParticipant(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"user_type": 1,
"user_role": 1,
}, meetingEventsIdentity{})
if got.Role != "participant" {
t.Fatalf("identity = %#v, want role=participant", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UserTypeApp(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "ou_app",
"user_name": "Demo Bot",
"user_type": 10,
"user_role": 1,
}, meetingEventsIdentity{})
if got.ParticipantType != "bot" {
t.Fatalf("identity = %#v, want participant_type=bot", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UnknownUserType(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u_unknown",
"user_name": "Unknown",
"user_type": 0,
"user_role": 1,
}, meetingEventsIdentity{})
if got.ParticipantType != "unknown" {
t.Fatalf("identity = %#v, want participant_type=unknown", got)
}
}
func TestMeetingEventsIdentityFromParticipant_IgnoresGenericTypeField(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"type": "bot",
}, meetingEventsIdentity{})
if got.ParticipantType != "human" {
t.Fatalf("identity = %#v, generic type field should not drive participant_type", got)
}
}
func TestMeetingEventSummary(t *testing.T) {
tests := []struct {
name string
@@ -933,6 +1400,22 @@ func TestMeetingEventSummary(t *testing.T) {
}
}
func TestMeetingEventsEventFromPayloadUsesActivityEventTypeFallback(t *testing.T) {
event := participantJoinedEvent()
delete(event, "event_type")
got := meetingEventsEventFromPayload(event, meetingEventsIdentity{})
if got.EventType != "participant_joined" {
t.Fatalf("EventType = %q, want participant_joined", got.EventType)
}
if len(got.Actors) != 1 {
t.Fatalf("actors len = %d, want 1: %#v", len(got.Actors), got.Actors)
}
if got.Actors[0].ID != "bot_001" {
t.Fatalf("actor id = %q, want bot_001", got.Actors[0].ID)
}
}
func TestEscapePrettyText(t *testing.T) {
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
want := `line1\nline2\t\r\u0007`

View File

@@ -199,6 +199,19 @@ func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
}
}
func TestWikiNodeListAcceptsOpaqueParentNodeToken(t *testing.T) {
t.Parallel()
const opaqueNodeToken = "Q6ZM_EXAMPLE_TOKEN"
token, err := normalizeWikiNodeListParentToken(opaqueNodeToken)
if err != nil {
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
}
if token != opaqueNodeToken {
t.Fatalf("token = %q, want %q", token, opaqueNodeToken)
}
}
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
t.Parallel()
@@ -224,11 +237,6 @@ func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
input: "wik_placeholder/child",
wantMsg: "raw wiki node token",
},
{
name: "document token",
input: "docx_placeholder_parent",
wantMsg: "must be a wiki node token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -351,10 +359,11 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
const parentNodeToken = "Q6ZM_EXAMPLE_TOKEN"
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=" + parentNodeToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
@@ -365,7 +374,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
"node_token": "wik_child",
"obj_token": "docx_child",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"parent_node_token": parentNodeToken,
"node_type": "origin",
"title": "Child Doc",
"has_child": false,
@@ -378,7 +387,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", parentNodeToken, "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -400,8 +409,8 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
}
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
if envelope.Data.Nodes[0]["parent_node_token"] != parentNodeToken {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], parentNodeToken)
}
}

View File

@@ -69,8 +69,8 @@ var WikiNodeGet = common.Shortcut{
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"--node-token accepts a raw wiki node_token, obj_token, or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens, pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
},
@@ -235,29 +235,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
).WithParam("--node-token")
} else {
spec.Token = tokenInput
if looksLikeWikiNodeToken(spec.Token) {
if spec.ObjType == "" {
spec.SourceKind = "raw-node"
// node_tokens take no obj_type; reject a conflicting flag rather
// than silently passing it (the API would just ignore it, but the
// mismatch signals caller confusion).
if spec.ObjType != "" {
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
spec.Token,
).WithParam("--obj-type")
}
} else {
spec.SourceKind = "raw-obj"
// A raw obj_token needs an explicit obj_type: get_node would
// otherwise default to "doc" and fail confusingly for docx /
// sheet / bitable / ... Fail fast with the same upfront contract
// as +node-delete instead of deferring to an opaque API error.
if spec.ObjType == "" {
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
).WithParam("--obj-type")
}
}
}
@@ -270,18 +251,6 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
return spec, nil
}
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
//
// This is a heuristic based on the current Lark token-naming convention, not a
// guaranteed invariant: if Lark ever introduces a non-node token type that
// also starts with `wik`, it would be misclassified. Worst case is a
// confusing API error (no data risk); revisit if the token scheme changes.
func looksLikeWikiNodeToken(token string) bool {
return strings.HasPrefix(strings.ToLower(token), "wik")
}
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
// don't need one.

View File

@@ -31,6 +31,22 @@ func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
}
}
func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
t.Parallel()
const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != opaqueNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, opaqueNodeToken)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": opaqueNodeToken}) {
t.Fatalf("RequestParams() = %v, want {token: %s}", got, opaqueNodeToken)
}
}
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
t.Parallel()
@@ -43,23 +59,30 @@ func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
}
}
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
t.Parallel()
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
// upfront instead of defaulting to "doc" and hitting an opaque API error.
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
}
}
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
}
}

View File

@@ -207,11 +207,6 @@ func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
).WithParam("--parent-node-token")
}
if !looksLikeWikiNodeToken(parentNodeToken) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
}
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
return "", err
}

View File

@@ -12,6 +12,16 @@ metadata:
妙搭应用属于用户资产。默认用 `--as user`认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
## 身份与一次性授权
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错:
```bash
lark-cli auth login --domain apps
```
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。
## 意图路由
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):

View File

@@ -11,7 +11,7 @@
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`shell 解析路径CLI 仅接收内容)。
- `--file``.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
- `--environment` 枚举:`dev` / `online`**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`
- `--environment` 枚举:`dev` / `online`**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`
- risk 是 `high-risk-write`SQL 可含 DML/DDL任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`详见下「Agent 规则」)。

View File

@@ -28,7 +28,7 @@
## 约定(先读)
- **环境 `--environment dev|online`所有 db 命令统一默认 `dev`**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment``+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义`+db-recovery-*` 作用于当前库,二者**没有** `--environment`
- **环境 `--environment dev|online`可省略**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`未开多环境的 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment``+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义**没有** `--environment`
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒validation / exit 2。路径在别处先 `cd` 过去或改成相对路径。
- **高危操作必须带 `--yes`**`+db-env-create``+db-data-import``+db-env-migrate``+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
- **时间参数按口语自然传**`--since`/`--until`/`--target`),格式见末尾。
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
## Agent 规则
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty``+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
- 四个高危命令(`+db-env-create``+db-data-import``+db-env-migrate``+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_requiredexit 10按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。

View File

@@ -1,6 +1,5 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
metadata:
requires:

View File

@@ -122,6 +122,8 @@ lark-cli docs +update --doc "<doc_id>" --command block_replace \
--content '<p>替换后的段落内容</p>'
```
投票 block 也使用 `block_replace` 做整块替换replacement 中的新 `<poll>` 会创建新的投票 block不继承旧投票的 block id、option id、票数、投票人、发布时间或当前用户选择如需把投票改成普通内容直接把 `--content` 写成目标 XML。
### block_delete — 删除指定 block
```bash

View File

@@ -9,6 +9,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|-|-|-|
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
| `<poll>` | 投票块,支持创建草稿、可选创建后发布 | `poll-type`, `is-anonymous`, `enable-due-time`, `due-time`, `publish-on-create` |
## 容器标签
|标签|说明|关键属性|
@@ -48,6 +49,40 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
## 投票 block
投票使用结构化 XML 表达。默认创建未发布草稿:空标题、两个空选项、单选、实名、无截止时间、结果策略固定为投票后可见。
```xml
<poll></poll>
```
带内容创建:
```xml
<poll poll-type="single" is-anonymous="false">
<poll-title>午饭吃什么?</poll-title>
<poll-option>米饭</poll-option>
<poll-option>面条</poll-option>
</poll>
```
创建后尝试发布:
```xml
<poll publish-on-create="true">
<poll-title>午饭吃什么?</poll-title>
<poll-option>米饭</poll-option>
<poll-option>面条</poll-option>
</poll>
```
公开可写属性只有:`poll-type="single|multiple"``is-anonymous="true|false"``enable-due-time="true|false"``due-time="毫秒时间戳"``publish-on-create="true|false"`。不要写入 `when-result-visible``option-id`、票数、投票人或当前用户投票状态。
读取已发布投票时XML 可能带只读结果字段,例如 `is-published``result-visible``user-count``poll-option count/percent/selected/voters-ref`。这些字段只用于展示,重新导入或 `block_replace` 时会被忽略;匿名投票不会通过 `reference_map` 暴露真实投票人。`voters-ref` 是读取详情的 opaque handle不是投票操作入口。
修改投票配置、替换已发布投票、把投票替换成普通内容,都使用 `block_replace`,语义是删除旧 block 并插入 replacement。新 `<poll>` 会创建新的投票 block不继承旧 block id、option id、票数、投票人、发布时间或当前用户选择。
# 四、块级复制与移动
## 移动block_move_after

View File

@@ -15,8 +15,6 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 本地生成或校验 SVG Slides / SVGlide 产物 | 先读 `references/svg-slides/README.md`,生成 local publish-ready bundle已有 publish-ready manifest 时用 `slides +create-svglide` 发布 | `references/svg-slides/README.md``lark-slides-create-svglide.md``scripts/validate_svg_deck.mjs``scripts/svg_slides_bundle.mjs` |
| 发布已有 SVGlide manifest | 只做发布:读取 manifest、校验 receipt/hash/root、本地图片上传并改写 href然后逐页提交 raw SVG需要 PPE 时只用 `scripts/dev-svglide-ppe-env.sh``ppe_svg_slides` 规则 | `slides +create-svglide --manifest manifest.json --as user``lark-slides-create-svglide.md` |
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get``lark-slides-replace-pages.md``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
@@ -31,8 +29,6 @@ metadata:
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — SVG Slides / SVGlide 与当前 XML/SXSD 工作流是不同协议。两者都使用 960x540 画布,但 SVG Slides 使用 `viewBox="0 0 960 540"` 和 `slide:*` SVG 语义XML/SXSD 使用 SML XML。处理 SVG Slides 生成或校验时,先读 [`references/svg-slides/README.md`](references/svg-slides/README.md),不要把 SVG 规则写进 `xml-schema-quick-ref.md`。**
**CRITICAL — PPT 生成与模板编辑硬约束PPT 的尺寸是 960x540确保主体内容在页面边界内。多用生图辅助搜图必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides回读理解每页版式后直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
@@ -80,7 +76,6 @@ lark-cli auth login --domain slides
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 发布已有 SVGlide manifest[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
@@ -135,14 +130,12 @@ lark-cli auth login --domain slides
| 场景 | 推荐方式 |
|------|----------|
| SVG Slides / SVGlide 生成产物(`manifest.json` + `slides/*.svg` | 先 bundle/校验,再用 `slides +create-svglide --manifest manifest.json --as user` 发布manifest 支持 1-50 页,不适用 `+create --slides` 的 10 页上限 |
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
> 这个 10 页限制只属于普通 XML 的 `slides +create --slides` 内联数组;不要用它判断 SVG Slides / SVGlide manifest 发布。
> [!IMPORTANT]
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
@@ -254,7 +247,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+create-svglide`](references/lark-slides-create-svglide.md) | 从已校验的 SVGlide manifest 发布 SVG Slidesmanifest 支持 1-50 页,并负责本地 `<image href>` 上传改写 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
@@ -271,7 +263,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**如果输入是 SVG Slides / SVGlide manifest直接走 `slides +create-svglide`,不要因超过 10 页而拆分;普通 Slides XML 才按复杂度选择 `slides +create --slides '[...]'` 或“两步创建”,其中 10 页上限只约束 `+create --slides` 的内联数组
2. **创建流程**简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`

View File

@@ -1,12 +0,0 @@
# SVG Slides Prompt Sources
This directory is the maintainer-only source for the SVG Slides runtime references.
Do not read these files while generating or editing a deck. Runtime agents should read `skills/lark-slides/references/svg-slides/` instead.
When changing prompt behavior:
1. Edit `full.debranded.md` and `split-manifest.json` here.
2. Run `node skills/lark-slides/scripts/svg_slides_extract_references.mjs`.
3. Run the SVG Slides tests and source coverage check.

File diff suppressed because it is too large Load Diff

View File

@@ -1,144 +0,0 @@
{
"version": "svg-slides.split-manifest.v1",
"source": "skills/lark-slides/prompt-sources/svg-slides/full.debranded.md",
"source_export": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.debranded.md",
"source_role": "maintainer_prompt_source_not_runtime_generation_context",
"sections": [
{
"id": "title",
"lines": [
1,
1
],
"target": "README.md"
},
{
"id": "system_prompt_workflow",
"lines": [
3,
221
],
"target": "system-prompt.md"
},
{
"id": "svg_reference",
"lines": [
223,
929
],
"target": "svg-reference.md"
},
{
"id": "resolve_design_brief",
"lines": [
931,
1144
],
"target": "resolve-design-brief/full.md"
},
{
"id": "deck_design_reference_catalog",
"lines": [
1146,
1298
],
"target": "deck-design-reference-catalog.md"
},
{
"id": "slide_outline_tool",
"lines": [
1300,
1318
],
"target": "tools/slide-outline.md"
},
{
"id": "activate_slides_edit_tool",
"lines": [
1320,
1326
],
"target": "tools/activate-slides-edit.md"
},
{
"id": "slides_edit_tool",
"lines": [
1328,
1345
],
"target": "tools/slides-edit.md"
},
{
"id": "svg_document_rules",
"lines": [
1347,
1351
],
"target": "tools/slides-edit.md"
},
{
"id": "image_usage",
"lines": [
1353,
1355
],
"target": "tools/slides-edit.md"
},
{
"id": "incremental_processing",
"lines": [
1357,
1395
],
"target": "tools/slides-edit.md"
},
{
"id": "finish_slides_edit_tool",
"lines": [
1397,
1403
],
"target": "tools/finish-slides-edit.md"
},
{
"id": "slide_organize_tool",
"lines": [
1405,
1411
],
"target": "tools/slide-organize.md"
},
{
"id": "compute_custom_shape_bbox_tool",
"lines": [
1413,
1419
],
"target": "tools/compute-custom-shape-bbox.md"
},
{
"id": "generate_svg_chart_tool",
"lines": [
1421,
2420
],
"target": "generate-svg-chart/full.md"
},
{
"id": "slides_convert_tool",
"lines": [
2422,
2459
],
"target": "tools/slides-convert.md"
},
{
"id": "slides_parse_template_tool",
"lines": [
2461,
2484
],
"target": "tools/slides-parse-template.md"
}
]
}

View File

@@ -1,160 +0,0 @@
# create-svglide boundary study
## Goal
Use `slides +create` as the design constraint sample for `slides +create-svglide`.
The central rule is:
```text
slides +create is a thin publisher for already-authored slide XML.
slides +create-svglide should be a thin publisher for already-authored SVGlide artifacts.
```
This document is evidence-first. It separates what the existing shortcut actually does from the broader generation and validation work described by the `lark-slides` skill.
## Source Surface
| Area | Files | Why it matters |
| --- | --- | --- |
| Go shortcut implementation | `shortcuts/slides/slides_create.go`, `shortcuts/slides/helpers.go`, `shortcuts/slides/slides_media_upload.go`, `shortcuts/slides/shortcuts.go` | Shows the real runtime boundary of `slides +create`. |
| Unit tests | `shortcuts/slides/slides_create_test.go` | Shows behavior that must not drift silently. |
| E2E proof | `tests/cli_e2e/slides/slides_create_workflow_test.go`, `tests/cli_e2e/slides/coverage.md` | Shows what is proven outside the shortcut body. |
| Skill and references | `skills/lark-slides/SKILL.md`, `skills/lark-slides/references/lark-slides-create.md`, `xml-schema-quick-ref.md`, `validation-checklist.md`, `troubleshooting.md` | Shows which work belongs to agent guidance or scripts instead of Go shortcut code. |
## `slides +create` Responsibility Matrix
| Responsibility | Evidence | Boundary meaning |
| --- | --- | --- |
| Register a write shortcut named `slides +create` for user and bot auth | `shortcuts/slides/slides_create.go:24-43`, `shortcuts/slides/shortcuts.go:8-17` | The command is a shortcut wrapper, not a general slide-generation subsystem. |
| Build a minimal presentation XML shell | `shortcuts/slides/slides_create.go:224-241` | The shortcut creates only the deck container: title plus 960x540 presentation metadata. |
| Create the online XML presentation | `shortcuts/slides/slides_create.go:125-148` | The first real API call is presentation creation. |
| Accept optional `--slides` as a JSON array of `<slide>` XML strings | `shortcuts/slides/slides_create.go:40-52` | Page content is supplied by the caller as final XML strings. |
| Enforce a maximum of 10 inline slide XML strings | `shortcuts/slides/slides_create.go:50-52` | This applies only to plain XML passed through `+create --slides`. SVGlide manifest publishing must use its own manifest page limit. |
| Detect local image placeholders in submitted XML | `shortcuts/slides/helpers.go:113-153` | The shortcut only interprets one small XML convention: `<img src=\"@path\">`. |
| Validate placeholder files before creating the presentation | `shortcuts/slides/slides_create.go:53-67` | Avoids creating an orphan deck for missing/oversized local images. |
| Upload placeholder images and replace them with file tokens | `shortcuts/slides/slides_create.go:163-177`, `shortcuts/slides/slides_media_upload.go:119-138`, `shortcuts/slides/helpers.go:283-309` | Image upload is helper orchestration, not content generation. |
| Submit each supplied slide XML string to the page-create API | `shortcuts/slides/slides_create.go:179-200` | The shortcut forwards caller-authored XML to the backend. |
| Report partial progress when page creation fails | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | It does not roll back; it tells the caller where to resume. |
| Output machine-readable creation results | `shortcuts/slides/slides_create.go:150-219` | The output is an API orchestration receipt. |
| Optionally attempt bot-created deck permission grant | `shortcuts/slides/slides_create.go:215-217`, `shortcuts/slides/slides_create_test.go:66-198` | Bot grant is post-create convenience, not part of content semantics. |
## Behavior Locks From Tests
| Behavior | Evidence | Boundary meaning |
| --- | --- | --- |
| User-mode create returns `xml_presentation_id`, `title`, and `url`, without `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | User-mode output is a creation receipt, not a validation report. |
| Missing `--title` becomes `Untitled` in dry-run and execution | `shortcuts/slides/slides_create_test.go:200-253` | Title normalization is a small deterministic convenience that belongs in the shortcut. |
| `--slides` creates the deck first, then adds pages, then returns `slide_ids` and `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | Page creation is orchestration after container creation. |
| `--slides []` behaves like no slides | `shortcuts/slides/slides_create_test.go:532-570` | Empty artifact lists should be explicit no-op additions, not special generators. |
| Invalid JSON and more than 10 inline slides fail validation with `Param == "--slides"` | `shortcuts/slides/slides_create_test.go:422-505` | Input-contract errors should be structured and routeable. |
| Missing `xml_presentation_id` from the backend fails | `shortcuts/slides/slides_create_test.go:255-283` | Creation success requires a usable resource id. |
| URL fallback is local and does not call Drive metas or batch query | `shortcuts/slides/slides_create_test.go:649-688` | Avoid adding extra API dependencies when a local receipt can be built. |
| Image placeholders are uploaded once per unique path and rewritten before page creation | `shortcuts/slides/slides_create_test.go:751-854` | Asset handling is publish-boundary plumbing, not design work. |
| Missing local placeholder files fail before any API call | `shortcuts/slides/slides_create_test.go:856-877` | Local artifact existence is a publish-blocking precondition. |
| Dry-run exposes the API plan shape and placeholder ids | `shortcuts/slides/slides_create_test.go:572-602`, `shortcuts/slides/slides_create_test.go:879-900` | Dry-run should describe orchestration, not execute validation-heavy side effects. |
| Readback is proven by E2E as a separate follow-up call | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`, `tests/cli_e2e/slides/coverage.md:9-16` | Readback is evidence for tests and delivery, not default `Execute` behavior. |
| Bot permission grant is non-fatal and tri-state: granted, skipped, or failed | `shortcuts/slides/slides_create_test.go:66-198` | Convenience post-actions must not turn creation success into failure. |
## `slides +create` Does Not Do
| Non-responsibility | Evidence | Design implication for `+create-svglide` |
| --- | --- | --- |
| Does not generate slide XML from a prompt | `shortcuts/slides/slides_create.go:40-43`, `shortcuts/slides/slides_create.go:158-205` | `+create-svglide` must not become `--topic -> deck`. |
| Does not deeply validate slide XML semantics | `shortcuts/slides/slides_create.go:44-69` | Only minimal publish-blocking validation belongs in the shortcut. |
| Does not preview or repair layout | `shortcuts/slides/slides_create.go:125-221` | Preview and repair belong in skill/scripts or a runner before publish. |
| Does not run readback inside `Execute` | `shortcuts/slides/slides_create.go:125-221`, `tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback is a test/proof step, not default shortcut behavior. |
| Does not guarantee atomic creation | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | New publish shortcuts should provide recovery context, not hide partial success. |
| Does not handle more than 10 inline pages | `shortcuts/slides/slides_create.go:18-21`, `shortcuts/slides/slides_create_test.go:441-465` | Bound the first version instead of building a complex batch manager. |
| Does not own visual quality | `skills/lark-slides/SKILL.md:91-127`, `skills/lark-slides/SKILL.md:153-160` | Visual quality gates belong before the shortcut consumes artifacts. |
## Counterexamples
| Tempting requirement | Why it looks tempting | What `slides +create` teaches |
| --- | --- | --- |
| Add readback by default | E2E uses readback to prove persistence. | E2E calls the get API after creation; `Execute` itself stops after outputting the create result. Keep readback optional or outside MVP. |
| Validate every page semantically before calling the backend | Better local errors sound useful. | `+create` only validates JSON shape, count, and local placeholder files; backend owns XML parsing. For SVGlide, only validate fields required to route and publish. |
| Run preview lint and auto-repair | SVGlide has preview tooling. | `+create` does not make layout judgments. Preview lint and repair must remain pre-publish tooling. |
| Accept a prompt and generate the deck | Higher-level UX is attractive. | `+create` consumes final submission artifacts. A prompt-to-deck runner would be a different command or script layer. |
| Hide partial failures by retrying/rebuilding automatically | It feels friendlier. | `+create` surfaces partial progress instead. Recovery should be explicit and resumable. |
## `slides +create-svglide` Allowed Extra Responsibilities
`+create-svglide` can be slightly heavier than `+create` only where SVGlide's input contract requires it. The extra work must still be publish-boundary work, not generation work.
| Extra responsibility | Allowed because | Limit |
| --- | --- | --- |
| Read a SVGlide manifest or run directory | Unlike `--slides`, SVGlide artifacts are file-based. | Normalize to one manifest model immediately; do not infer design intent. |
| Validate manifest schema and page order | Needed to know what to publish. | Validate shape and required fields only. |
| Validate page file existence and path safety | Equivalent to `+create` validating `@path` placeholders. | Do not inspect aesthetics or text quality. |
| Validate publish-required SVGlide fields | The target publish API or parser may require namespace, contract/version, dimensions, or roles before it can accept a page. | Check only required markers; do not rewrite ordinary SVG into protocol SVG in the shortcut. |
| Upload declared local assets | Equivalent to `+create` uploading `@path` images. | Upload and token replacement only; no asset search or generation. |
| Submit SVGlide pages to the target publish API | Equivalent to `+create` submitting each slide XML string. | Keep output and partial-progress behavior explicit; do not assume the CLI must convert to XML if the backend can consume SVGlide directly. |
## `slides +create-svglide` Must Not Own
| Responsibility | Owner |
| --- | --- |
| Research, outline, design brief, slide content planning | `skills/lark-slides` guidance and external runner/scripts |
| SVG authoring | Agent or runner before publish |
| Preview rendering, preview lint, and repair loop | Skill scripts or runner before publish |
| Visual quality scoring | Skill/scripts/quality gate, not shortcut `Execute` |
| Readback as default success criterion | E2E or optional verification flag |
| PPE/Whistle routing as core naming | Environment/profile layer only |
## MVP Scope
Recommended first implementation:
```bash
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
```
MVP behavior:
1. Parse manifest.
2. Validate required fields, page order, file existence, path safety, dimensions, and minimal SVGlide contract markers.
3. Create presentation shell.
4. Upload local assets declared in the manifest.
5. Submit pages to the backend.
6. Output `xml_presentation_id`, `url`, `page_ids` or `slide_ids`, uploaded asset count, and partial-progress context on failure.
MVP exclusions:
1. No prompt input.
2. No generation stages.
3. No preview repair.
4. No default readback.
5. No PPE-specific command name, directory name, or type name.
## Test Boundary For `+create-svglide`
The first test suite should mirror the shape of `slides +create` tests instead of proving the whole SVGlide generation pipeline.
| Test area | Required proof |
| --- | --- |
| Input contract | Invalid manifest, missing page file, unsafe path, and unsupported page count fail with structured params. |
| Dry-run | Shows create, asset upload, and page publish steps with placeholder presentation id and deterministic step labels. |
| Asset handling | Duplicate local assets upload once; page payloads reference uploaded tokens before publish. |
| Partial failure | If the deck exists and page N fails, error includes presentation id, failed page index, and successfully published page count. |
| Bot grant | Inherit user/bot output behavior from `slides +create`; grant failure is reported but not promoted to create failure. |
| E2E | Create/publish result is asserted first; optional readback is a separate proof step unless the command explicitly adds a `--readback` contract. |
## Team Finding
The effective research team for this boundary is:
| Role | Scope |
| --- | --- |
| Code Reader | Extract runtime responsibilities from Go implementation. |
| Test Reader | Extract behavior locks and prove what is outside `Execute`. |
| Skill Boundary Reader | Separate agent/script responsibilities from shortcut responsibilities. |
| Architect/Skeptic | Reject over-broad scope and map only proven `+create` patterns into `+create-svglide`. |
The team's proof standard is not "we read the files"; it is:
```text
Every proposed +create-svglide responsibility must map to either:
1. an existing +create responsibility, or
2. a minimal extra responsibility forced by SVGlide's artifact input shape.
```

View File

@@ -1,160 +0,0 @@
# create-svglide 边界研究
## 目标
`slides +create` 作为 `slides +create-svglide` 的设计约束样本。
核心规则是:
```text
slides +create 是已经写好的 slide XML 的薄发布器。
slides +create-svglide 也应该是已经生成好的 SVGlide 产物的薄发布器。
```
本文档以证据为先,区分现有 shortcut 真实承担的职责,以及 `lark-slides` skill 中描述的更宽泛的生成与验证工作。
## 研究范围
| 范围 | 文件 | 作用 |
| --- | --- | --- |
| Go shortcut 实现 | `shortcuts/slides/slides_create.go``shortcuts/slides/helpers.go``shortcuts/slides/slides_media_upload.go``shortcuts/slides/shortcuts.go` | 确认 `slides +create` 的真实运行时边界。 |
| 单元测试 | `shortcuts/slides/slides_create_test.go` | 确认可被测试锁定、不能随意漂移的行为。 |
| E2E 证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go``tests/cli_e2e/slides/coverage.md` | 确认哪些证明发生在 shortcut 外部。 |
| Skill 与 references | `skills/lark-slides/SKILL.md``skills/lark-slides/references/lark-slides-create.md``xml-schema-quick-ref.md``validation-checklist.md``troubleshooting.md` | 确认哪些工作属于 agent 指导或脚本,而不是 Go shortcut。 |
## `slides +create` 职责矩阵
| 职责 | 证据 | 边界含义 |
| --- | --- | --- |
| 注册一个名为 `slides +create` 的写操作 shortcut支持 user 和 bot 身份 | `shortcuts/slides/slides_create.go:24-43``shortcuts/slides/shortcuts.go:8-17` | 这是 shortcut 封装,不是通用幻灯片生成系统。 |
| 构造最小 presentation XML 外壳 | `shortcuts/slides/slides_create.go:224-241` | shortcut 只创建 deck 容器:标题和 960x540 presentation 元数据。 |
| 创建在线 XML presentation | `shortcuts/slides/slides_create.go:125-148` | 第一个真实 API 调用是创建 presentation。 |
| 接收可选 `--slides`,格式为 `<slide>` XML 字符串 JSON 数组 | `shortcuts/slides/slides_create.go:40-52` | 页面内容由调用方以最终 XML 字符串形式提供。 |
| 限制一次内联提交最多 10 页 slide XML | `shortcuts/slides/slides_create.go:50-52` | 这只适用于普通 XML 的 `+create --slides` 内联数组SVGlide manifest 发布必须使用自己的 manifest 页数上限。 |
| 检测已提交 XML 里的本地图片占位符 | `shortcuts/slides/helpers.go:113-153` | shortcut 只理解一个很窄的 XML 约定:`<img src="@path">`。 |
| 创建 presentation 前校验占位符文件 | `shortcuts/slides/slides_create.go:53-67` | 避免因为本地图片缺失或超限而创建孤儿 deck。 |
| 上传占位符图片并替换为 file token | `shortcuts/slides/slides_create.go:163-177``shortcuts/slides/slides_media_upload.go:119-138``shortcuts/slides/helpers.go:283-309` | 图片上传是发布边界上的 helper 编排,不是内容生成。 |
| 把每个调用方提供的 slide XML 字符串提交给 page-create API | `shortcuts/slides/slides_create.go:179-200` | shortcut 把调用方写好的 XML 转交给后端。 |
| 页面创建失败时报告部分进度 | `shortcuts/slides/slides_create.go:194-196``shortcuts/slides/slides_create_test.go:354-420` | 不回滚;告诉调用方从哪里恢复。 |
| 输出机器可读的创建结果 | `shortcuts/slides/slides_create.go:150-219` | 输出是 API 编排回执。 |
| bot 创建 deck 后可选尝试给当前用户授权 | `shortcuts/slides/slides_create.go:215-217``shortcuts/slides/slides_create_test.go:66-198` | bot grant 是创建后的便利动作,不属于内容语义。 |
## 测试锁定的行为
| 行为 | 证据 | 边界含义 |
| --- | --- | --- |
| user 模式创建返回 `xml_presentation_id``title``url`,不返回 `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | user 模式输出是创建回执,不是验证报告。 |
| 省略 `--title`dry-run 和真实执行都归一为 `Untitled` | `shortcuts/slides/slides_create_test.go:200-253` | 标题归一是适合放在 shortcut 内的小型确定性便利。 |
| `--slides` 会先创建 deck再添加页面最后返回 `slide_ids``slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | 页面创建是容器创建后的编排。 |
| `--slides []` 等价于不传 slides | `shortcuts/slides/slides_create_test.go:532-570` | 空产物列表应是明确的无追加操作,不应触发特殊生成逻辑。 |
| 非法 JSON 和超过 10 个内联 slides 会以 `Param == "--slides"` 的校验错误失败 | `shortcuts/slides/slides_create_test.go:422-505` | 输入契约错误必须结构化,便于调用方路由处理。 |
| 后端缺少 `xml_presentation_id` 时失败 | `shortcuts/slides/slides_create_test.go:255-283` | 创建成功必须拿到可用资源 id。 |
| URL fallback 在本地构造,不调用 Drive metas 或 batch query | `shortcuts/slides/slides_create_test.go:649-688` | 能用本地回执构造的内容,不应增加额外 API 依赖。 |
| 图片占位符按唯一路径上传一次,并在页面创建前完成替换 | `shortcuts/slides/slides_create_test.go:751-854` | 素材处理是发布边界的管道能力,不是设计工作。 |
| 本地占位符文件缺失时,在任何 API 调用前失败 | `shortcuts/slides/slides_create_test.go:856-877` | 本地产物存在性是发布前置条件。 |
| Dry-run 暴露 API 计划形状和占位 id | `shortcuts/slides/slides_create_test.go:572-602``shortcuts/slides/slides_create_test.go:879-900` | Dry-run 应描述编排计划,而不是执行重型校验副作用。 |
| Readback 在 E2E 中作为单独 follow-up 调用证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85``tests/cli_e2e/slides/coverage.md:9-16` | Readback 是测试和交付证据,不是默认 `Execute` 行为。 |
| Bot 授权是非致命三态granted、skipped、failed | `shortcuts/slides/slides_create_test.go:66-198` | 便利性的后置动作不应把创建成功升级成失败。 |
## `slides +create` 不负责的事情
| 非职责 | 证据 | 对 `+create-svglide` 的设计含义 |
| --- | --- | --- |
| 不从 prompt 生成 slide XML | `shortcuts/slides/slides_create.go:40-43``shortcuts/slides/slides_create.go:158-205` | `+create-svglide` 不能变成 `--topic -> deck`。 |
| 不深度校验 slide XML 语义 | `shortcuts/slides/slides_create.go:44-69` | shortcut 内只应放发布阻塞级的最小校验。 |
| 不预览或修复布局 | `shortcuts/slides/slides_create.go:125-221` | preview 和 repair 属于发布前的 skill/scripts 或 runner。 |
| 不在 `Execute` 内做 readback | `shortcuts/slides/slides_create.go:125-221``tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback 是测试/证明步骤,不是默认 shortcut 行为。 |
| 不保证原子创建 | `shortcuts/slides/slides_create.go:194-196``shortcuts/slides/slides_create_test.go:354-420` | 新发布类 shortcut 应提供恢复上下文,而不是隐藏部分成功。 |
| 不处理超过 10 个内联页面 | `shortcuts/slides/slides_create.go:18-21``shortcuts/slides/slides_create_test.go:441-465` | 第一版应设边界,而不是一开始实现复杂批处理器。 |
| 不负责视觉质量 | `skills/lark-slides/SKILL.md:91-127``skills/lark-slides/SKILL.md:153-160` | 视觉质量门禁应发生在 shortcut 消费产物之前。 |
## 反例
| 诱人的需求 | 为什么看起来合理 | `slides +create` 给出的约束 |
| --- | --- | --- |
| 默认加入 readback | E2E 用 readback 证明持久化。 | E2E 是创建后另调 get API`Execute` 输出创建结果后即结束。Readback 应保持可选或放在 MVP 外。 |
| 调后端前语义校验每一页 | 本地错误更友好。 | `+create` 只校验 JSON 形状、页数、本地占位符文件XML 解析由后端负责。SVGlide 也只校验发布路由必需字段。 |
| 运行 preview lint 并自动 repair | SVGlide 有 preview 工具链。 | `+create` 不做布局判断。Preview lint 和 repair 应留在发布前工具链。 |
| 接受 prompt 并生成 deck | 高层 UX 很吸引人。 | `+create` 消费最终提交物。Prompt-to-deck runner 应是另一层命令或脚本。 |
| 通过自动重试/重建隐藏部分失败 | 看起来更友好。 | `+create` 暴露部分进度。恢复应该显式、可续跑。 |
## `slides +create-svglide` 允许新增的职责
`+create-svglide` 只能在 SVGlide 输入契约强制要求的地方比 `+create` 稍重。新增工作仍必须属于发布边界,而不是生成边界。
| 额外职责 | 允许原因 | 限制 |
| --- | --- | --- |
| 读取 SVGlide manifest 或 run directory | 与 `--slides` 不同SVGlide 产物是文件型产物。 | 立即归一化为一个 manifest 模型;不要推断设计意图。 |
| 校验 manifest schema 和页序 | 需要知道要发布什么。 | 只校验形状和必填字段。 |
| 校验页面文件存在性和路径安全 | 等价于 `+create` 校验 `@path` 占位符。 | 不检查美观度或文本质量。 |
| 校验发布必需的 SVGlide 字段 | 目标发布 API 或 parser 可能需要 namespace、contract/version、尺寸或 role 才能接收页面。 | 只检查必需标记;不要在 shortcut 中把普通 SVG 重写成协议 SVG。 |
| 上传声明的本地素材 | 等价于 `+create` 上传 `@path` 图片。 | 只做上传和 token 替换;不做素材搜索或生成。 |
| 把 SVGlide 页面提交给目标发布 API | 等价于 `+create` 提交每个 slide XML 字符串。 | 保持输出和部分进度语义明确;如果后端能直接消费 SVGlide不要假设 CLI 必须转 XML。 |
## `slides +create-svglide` 必须不拥有的职责
| 职责 | 所属边界 |
| --- | --- |
| research、outline、design brief、slide content planning | `skills/lark-slides` 指导和外部 runner/scripts |
| SVG authoring | agent 或 runner在发布前完成 |
| preview rendering、preview lint、repair loop | skill scripts 或 runner在发布前完成 |
| 视觉质量评分 | skill/scripts/quality gate不属于 shortcut `Execute` |
| readback 作为默认成功标准 | E2E 或可选验证 flag |
| PPE/Whistle 路由进入核心命名 | 只能属于环境/profile 层 |
## MVP 范围
推荐第一版实现:
```bash
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
```
MVP 行为:
1. 解析 manifest。
2. 校验必填字段、页序、文件存在性、路径安全、尺寸、最小 SVGlide contract 标记。
3. 创建 presentation 外壳。
4. 上传 manifest 声明的本地素材。
5. 把页面提交给后端。
6. 输出 `xml_presentation_id``url``page_ids``slide_ids`、上传素材数量,以及失败时的部分进度上下文。
MVP 排除项:
1. 不接受 prompt 输入。
2. 不包含生成阶段。
3. 不做 preview repair。
4. 不默认 readback。
5. 不在命令名、目录名或类型名中包含 PPE。
## `+create-svglide` 测试边界
第一版测试应镜像 `slides +create` 的测试形状,而不是证明完整 SVGlide 生成流水线。
| 测试范围 | 必须证明 |
| --- | --- |
| 输入契约 | 非法 manifest、缺失页面文件、不安全路径、不支持的页数以结构化 param 失败。 |
| Dry-run | 展示 create、asset upload、page publish 步骤,包含占位 presentation id 和确定性的 step label。 |
| 素材处理 | 重复本地素材只上传一次;页面 payload 在发布前引用已上传 token。 |
| 部分失败 | deck 已存在但第 N 页失败时,错误包含 presentation id、失败页序号、已成功发布页数。 |
| Bot grant | 继承 `slides +create` 的 user/bot 输出行为grant 失败不升级成 create 失败。 |
| E2E | 先断言 create/publish 结果;可选 readback 作为单独证明步骤,除非命令显式加入 `--readback` 契约。 |
## Team 结论
适合研究这个边界的 team 是:
| 角色 | 范围 |
| --- | --- |
| Code Reader | 从 Go 实现中抽取运行时职责。 |
| Test Reader | 抽取行为锁定点,并证明哪些行为不在 `Execute` 内。 |
| Skill Boundary Reader | 区分 agent/script 职责和 shortcut 职责。 |
| Architect/Skeptic | 拒绝过宽 scope只把已被 `+create` 证明的模式映射到 `+create-svglide`。 |
这个 team 的证明标准不是“读过文件”,而是:
```text
每一个 proposed +create-svglide 职责都必须映射到:
1. 一个已有 +create 职责;或
2. 一个由 SVGlide 产物输入形态强制产生的最小额外职责。
```

View File

@@ -1,180 +0,0 @@
# slides +create-svglide
`slides +create-svglide` publishes an existing SVGlide bundle to Lark Slides.
It is a publisher, not a generator.
Use it only after the SVG Slides local generation/validation layer has produced a publish-ready manifest and page files.
## What It Owns
- Read `svglide.manifest.v1`.
- Validate `protocol == "svg-slides.v1"`, `publish_ready == true`, required bundle receipts, `receipts.validate_svg_deck.totalErrors == 0`, visual-system/color-rhythm zero-error reports, page order, page count, 960x540 size, local file paths, and `pages[].sha256`.
- Verify each page is still raw SVG by re-reading the file, checking sha256, checking the XML root with an XML decoder, and rejecting Slides XML/SXSD/HTML/raster/data URL fallback.
- Upload local SVG image resources and rewrite `<image href>` / `<image xlink:href>` values to Slides media `file_token`s before submitting each raw SVG page.
- Create the presentation shell.
- Submit each page payload in manifest order.
- Return `xml_presentation_id`, URL, slide IDs, page mapping, source/submitted page hashes, and image upload/rewrite receipts.
## What It Does Not Own
- Research, outline, or content planning.
- SVG/SVGlide authoring.
- Preview, visual QA, or repair loops.
- Automatically running online readback; callers must run the readback gate after publish.
- PPE routing or Whistle setup. For internal PPE validation, use the dev-only script below.
- Remote image fetching, image generation, or FileMetaMap shaping.
Generated SVG decks must not bypass this command by calling `slides xml_presentation.slide create` directly. Direct raw API calls can prove transport only; they do not prove that the generated deck passed the SVGlide parser contract, bundle receipts, asset rewrite, or readback gates.
## Page Count Boundary
`slides +create-svglide` accepts 1-50 pages in `manifest.pages`.
This limit is independent from `slides +create --slides`, whose inline JSON array is limited to 10 entries.
Do not split a 12-page SVG Slides deck just because `+create --slides` has a 10-page inline payload limit.
## Internal PPE Lane
For internal online validation of SVG Slides, the only supported dev lane is `ppe_svg_slides`.
Do not use older local lanes such as `ppe_svglide` or `ppe_pure_svg`.
Set or clear the rule with the tracked dev-only script:
```bash
# enable the canonical rule
. ./scripts/dev-svglide-ppe-env.sh pre
# print the canonical Whistle rules without applying them
./scripts/dev-svglide-ppe-env.sh rules
# disable the canonical rule
. ./scripts/dev-svglide-ppe-env.sh off
```
The expected effective request route is:
```text
host: open.feishu-pre.cn
Env: Pre_release
x-tt-env: ppe_svg_slides
x-use-ppe: 1
```
This script is not part of production publish behavior. It exists only to make local/PPE validation reproducible.
## Manifest
Minimum shape:
```json
{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": "Deck title",
"size": { "width": 960, "height": 540 },
"publish_ready": true,
"published": false,
"pages": [
{ "id": "page-001", "index": 1, "file": "slides/page-001.svg", "sha256": "..." }
],
"receipts": {
"validate_svg_deck": "receipts/validate_svg_deck.json",
"prompt_read_trace": "receipts/prompt_read_trace.json",
"asset_readiness": "receipts/asset_readiness.json",
"typography_plan": "receipts/typography_plan.json",
"visual_system": "receipts/visual_system.json",
"visual_system_report": "receipts/visual_system_report.json",
"color_rhythm": "receipts/color_rhythm.json",
"frame_discipline": "receipts/frame_discipline.json",
"parser_contract": "receipts/parser_contract.json",
"readability_safety": "receipts/readability_safety.json"
}
}
```
All page files and receipt paths are relative to the manifest directory. Absolute paths and `..` paths are rejected. Unknown manifest fields are ignored for forward compatibility. `published` is bundle state and is not used as a blocker by `+create-svglide`.
Local SVG image hrefs are resolved inside the manifest/run root, uploaded to the created presentation, and rewritten to returned media tokens before page submission. Remote URLs, data URLs, fragment hrefs, unresolved paths, `@path` placeholders, and paths escaping the root are rejected.
## Usage
Dry-run first:
```bash
lark-cli slides +create-svglide \
--as user \
--manifest manifest.json \
--dry-run
```
Publish:
```bash
lark-cli slides +create-svglide \
--as user \
--manifest manifest.json \
--title "Optional title override"
```
For online generated decks, publish is not complete until readback passes:
```bash
node skills/lark-slides/scripts/svg_slides_readback_gate.mjs <deck-dir> --presentation-id <xml_presentation_id> --json > receipts/online_readback_gate.json
```
The gate must report `ok: true`. A successful `slide.create` transport receipt without this gate is not sufficient evidence that text and images survived parser conversion.
Expected output includes:
- `xml_presentation_id`
- `url`
- `slides_added`
- `slide_ids`
- `pages[].source_sha256`
- `pages[].submitted_sha256`
- `images_uploaded`
- `image_rewrites`
### Page-Level Repair Loop
When a validation gate fails, do not regenerate the full deck by default. Generate page-level repair context from receipts:
Page-level repair context can be generated from these receipt families:
- `validate_svg_deck`: parser-level SVG errors.
- `frame_discipline`: text-adjacent frame misuse.
- `readability_safety`: overlap, out-of-bounds, and missing text-over-image mask failures.
- `asset_readiness_report`: only page-level asset execution failures, such as missing embedded primary visuals or missing mask shapes.
- `typography_plan_report`: only page-level SVG font execution failures, such as a slide using a font stack outside the plan.
- `visual_system_report`: only page-level SVG palette execution failures, such as one slide using a color outside the locked palette.
Deck-level failures still require chain-level correction and must not be converted into page repair contexts:
- missing prompt reads
- missing web-search evidence
- missing prepared assets
- invalid deck visual identity
- invalid typography plan fields
- invalid visual_system palette/rhythm fields
- color_rhythm deck-level failures, such as dark-page budget overflow, excessive transition ratio, or alternating full-page dark/light sequences
```bash
node skills/lark-slides/scripts/svg_slides_repair_context.mjs <deck-dir> --out <deck-dir>/repairs --json
node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs <deck-dir> --write <deck-dir>/receipts/repair_hashes.before.json --json
```
Repair only the failed `slides/slide_NN.svg` files listed in `repairs/repair_context.json`. After replacement, verify non-failed pages did not change:
```bash
node skills/lark-slides/scripts/svg_slides_repair_hash_guard.mjs <deck-dir> --baseline <deck-dir>/receipts/repair_hashes.before.json --allowed slides/slide_NN.svg --json
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"
```
The repair unit is one slide file. The acceptance unit remains the full deck.
## Failure Handling
The command validates local bundle state and local image paths before creating the presentation. If image upload fails after presentation creation, the error hint includes the created presentation ID and says no pages were added.
If adding a later page fails, the command does not roll back the created presentation. The error hint includes presentation ID, failed page ordinal, page ID, and the number of pages already added.
If the backend rejects raw SVG in `slide.content`, stop. Do not fall back to screenshots, HTML, generated XML, or rasterized pages inside this shortcut.

View File

@@ -56,7 +56,7 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
| 参数 | 必填 | 说明 |
|------|------|------|
| `--title` | 否 | 演示文稿标题(不传则默认 "Untitled" |
| `--slides` | 否 | slide 内容 JSON 数组,每个元素是一个 `<slide>` XML 字符串(普通 XML 内联最多 10 个;更多普通 XML请先用 `+create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加SVG Slides / SVGlide manifest 请改用 `+create-svglide` |
| `--slides` | 否 | slide 内容 JSON 数组,每个元素是一个 `<slide>` XML 字符串(最多 10 个;超过 10 页请先用 `+create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加) |
## `--slides` 参数格式

View File

@@ -1,57 +0,0 @@
# SVG Slides Local Generation
This directory is the runtime reference contract for local SVG Slides generation.
Do not use maintainer prompt-source files as generation context. Runtime agents should follow the read routes below.
## Read Routes
| Task | Read first | Then read |
|---|---|---|
| Understand the main SVG Slides workflow | `system-prompt.md` | `svg-reference.md`, `tools/slides-edit.md` |
| Author or repair SVG protocol content | `svg-reference.md` | `tools/slides-edit.md`, `tools/compute-custom-shape-bbox.md` |
| Resolve deck design direction | `resolve-design-brief/README.md` | `deck-design-reference-catalog.md` |
| Choose or audit typography | `resolve-design-brief/style-director-prompt.md` | `system-prompt.md`, `svg-reference.md`, `validation/typography-plan.md` |
| Generate or revise charts | `generate-svg-chart/README.md` | matching files under `generate-svg-chart/snippets/` |
| Continue or convert an existing deck | `tools/slides-convert.md` | `tools/slide-organize.md`, `tools/slides-edit.md` |
| Prove asset search/readiness | `validation/asset-readiness.md` | `../../../scripts/svg_slides_asset_readiness.mjs`, `../../../scripts/svg_slides_visual_identity_check.mjs` |
| Prove typography planning | `validation/typography-plan.md` | `../../../scripts/svg_slides_typography_plan.mjs` |
| Prove visual system and color rhythm | `validation/visual-system.md` | `../../../scripts/svg_slides_visual_system.mjs`, `../../../scripts/svg_slides_color_rhythm.mjs` |
| Prove frame discipline | `validation/frame-discipline.md` | `../../../scripts/svg_slides_frame_discipline.mjs` |
| Prove readability safety | `validation/README.md` | `../../../scripts/svg_slides_readability_safety.mjs` |
| Prove prompt reads for a generated deck | `validation/prompt-read-trace.md` | `../../scripts/svg_slides_prompt_trace.mjs` |
| Audit a delivered deck before answering the user | `validation/post-delivery-audit.md` | `validation/asset-readiness.md`, `../../../scripts/svg_slides_readback_gate.mjs` |
## Boundary
Local SVG Slides generation and validation produce local artifacts. Publishing to Lark Slides is outside this reference family unless a separate publisher explicitly consumes the generated bundle.
## Prompt Read Trace Gate
Every generated local bundle must include `receipts/prompt_read_trace.json` before `manifest.json` may set `publish_ready=true`.
The trace records the runtime prompt/reference Markdown files read for the generation task, including path, role, reason, and SHA-256.
## Asset Readiness Gate
Every generated local bundle must include `receipts/asset_readiness.json` before `manifest.json` may set `publish_ready=true`.
The receipt records the deck's visual identity fingerprint, default image-search policy, required visual subjects, prepared local assets, and per-slide layout families.
## Typography Plan Gate
Every generated local bundle must include `receipts/typography_plan.json` before `manifest.json` may set `publish_ready=true`.
The receipt records the deck's selected font category, display/body/label stacks, requested fonts, fallback decisions, source, and rejected substitutions. Slide SVG text must use only those planned stacks.
## Visual System Gate
Every generated local bundle must include `receipts/visual_system.json`, then pass `visual_system_report` and `color_rhythm` validation before `manifest.json` may set `publish_ready=true`.
The receipt records deck-level palette tokens and per-slide background rhythm. It preserves slide layout freedom, but prevents adjacent pages from jumping between unrelated full-page color systems.
## Frame Discipline Gate
Every generated local bundle must pass frame-discipline validation before `manifest.json` may set `publish_ready=true`.
The gate keeps text unframed by default and rejects stroked text containers that lack an explicit semantic `data-frame-role`.

View File

@@ -1,158 +0,0 @@
# Deck Design Reference Catalog
## Context
- Role: reference
- Purpose: Narrative archetypes and depth rubric consumed by the design brief prompt.
## Content
# Deck design reference catalog
Use this as your reference library: SELECT the best-fit narrative archetype for the deck's scenario, then ADAPT to specifics, and set depth per the rubric. Do not invent from scratch when a fit exists.
## Narrative archetypes (pick the closest, adapt the spine)
### Magnitude-First Investor Pitch
- When: Raising capital from investors who scan dozens of decks/day: accelerator demo days, seed/Series A-C, CVC, emerging-market, SMB/SBA, government-RFP-as-pitch. The reader is deciding whether to take a meeting, not learning.
- Spine: cover -> one-sentence what-you-do (<=7 words) -> problem/why-now -> traction-or-unit-economics moved EARLY (slide 3-4) -> product (<=2 screens) -> market (bottom-up) -> moat/why-us -> team -> the ask. Stage scales detail: demo-day=10 slides one-line-each; A=14 with magnitude metrics; B/C=18 with cohort triangle + Rule-of-40; CVC inserts strategic-fit matrix; EM adds dual-currency + FX; SBA adds T12M + DSCR.
- Discipline: One idea per slide, one chart per concept. Traction is magnitude-first (ARR/NDR/burn, never a lone MoM%); weak metrics get an explainer slide, never hidden.
### Answer-First Decision Deck (Pyramid)
- When: Any moment where a named principal must DECIDE in the room or before it: board pre-reads, exec/IC decision briefs, IT/capital-investment approvals, M&A/budget defenses, policy decision memos, 1-page ExCo asks, QBRs framed as resource asks, deck-rescue/CEO-polish passes.
- Spine: recommendation/BLUF on slide <=3 (<=25 words) -> Why-Now / What-We-Need / Trade-offs grid -> sized evidence (variance waterfall, TCO, football-field, scorecard) -> risks each paired with mitigation+owner -> Decision-Ask footer (number + named owner + decide-by date) -> analysis demoted to appendix.
- Discipline: Conclusion-first: the ask survives if nothing else is read. Every body title is a full-sentence takeaway; every chart names its so-what; name what you give up.
### Operating Cadence Review
- When: Recurring status-to-decision rhythm for an internal leadership audience: MOR/QBR, OKR reviews, SteerCo/kickoff, CRM-funnel reviews, weekly KPI/WBR, regional-to-HQ reviews, board pre-reads with scorecards.
- Spine: cover -> R/Y/G scorecard (slide 02) -> headline verdict -> plan-vs-actual variance waterfall -> operating metrics / funnel -> wins -> misses-with-named-owner-and-lesson -> risks (prob x impact, owner, leading indicator) -> Decisions Required / Asks (with $/owner/date) -> commits.
- Discipline: Green gets zero airtime; spend time only on red. Never >80% green (sandbagging flag); every open decision carries a named owner and decide-by date.
### Consulting Engagement Argument
- When: Tier-1 strategy artifacts that argue a case end-to-end: diagnostic readouts, final engagement decks, framework packs, capability/proposal pitches, 3-year/5-year strategic plans, transformation/org-strategy reviews.
- Spine: cover -> Governing Thought / Minto SCQA answer (slide 02) -> approach -> chaptered evidence dividers each ending in a So-What -> options compared (effort-vs-impact 2x2, dollar-sized) -> Where-to-Play x How-to-Win choice cascade -> roadmap with named owners -> risks/sensitivities -> decisions required -> deep appendix.
- Discipline: One framework per chapter (no blending); argument-logic not chronology; converge analysis into 3 named/owned/sequenced moves with quantified resource implications.
### Thesis-Driven Market/Investment Report
- When: A POV that must move a sophisticated reader's model: VC landscape/thesis decks, industry deep-dives, equity-research earnings notes, IBD roadshows, capital-markets days, analyst briefings, AI-capability/vertical briefings.
- Spine: cover -> falsifiable Thesis Sentence (slide 02) -> exec summary -> evidence stack (TAM triangulation, value chain, Five Forces, bridges/waterfalls) -> per-segment or per-workflow decomposition tables -> traction/valuation -> historical analogue -> disagreements/anti-thesis -> Bets-We'd/Won't-Make -> risks/catalysts -> sourced references.
- Discipline: A claim, not a topic, with a required anti-thesis. Every number is source-traceable (10-K/earnings/dated consensus) and decomposed to task or driver level, not a market tour.
### Expert Research Talk (Thesis-Then-Evidence)
- When: Argued scholarly presentations to an expert/committee audience: NeurIPS-style orals, PhD/thesis/survey defenses, job talks, humanities/comp-lit/divinity seminars, conference readouts, lab meetings, review-article companions.
- Spine: cover -> defensible Thesis/Contribution sentence (slide 02, passes 'so what?'+'who disagrees?') -> gap/scope -> background -> per-contribution or per-cluster blocks (setup -> method -> one headline result panel -> ablation -> limits) -> synthesis -> limitations BEFORE conclusion -> falsifiable forward bets -> Works Cited + pre-empted-questions appendix.
- Discipline: Argue at claim/contribution altitude, not coverage; one claim per slide, one chart per claim with baseline+delta in the title. Cite-as-ethics; name what you won't settle.
### Active-Learning Instructional Session
- When: Time-boxed teaching where the learner must DO something: K-12/TA/recitation lessons, university STEM lectures, coding/Excel/cert/language/medical-CME, vocational bench, exam-prep, nursing preclinical. One concept/skill per session.
- Spine: cover -> single measurable objective/can-do (slide 02, code-tagged) -> hook/retrieval warm-up -> I-Do worked example -> Check -> We-Do -> You-Do -> deliberate break/error to debug -> common-errors slide -> exit ticket / pass-fail rubric mapped 1:1 to the objective.
- Discipline: Gradual release with a check at every time-chunk; worked-example fading (full -> partial -> solo). One objective per session; close on a graded retrieval, never 'thank you'.
### Behavior-Change Compliance/Safety Training
- When: Mandatory training that must change a frontline decision AND survive an auditor: FCPA/ethics, EHS/safety drills, caregiver/CNA certification, SaaS-admin/sales enablement recerts, employee onboarding with attestation.
- Spine: cover -> why-we're-here -> named speak-up/stop-work channel -> opening real (anonymized) incident -> policy/regulation frame -> scenario decision drills (red/green cards) -> documentation/warning-signs -> tracked attestation or signed competency card.
- Discipline: Scenario-first, policy-second; the named channel (speak-up / stop-work / system-of-record) appears on every policy slide. Close with a tracked acknowledgement that doubles as the audit trail.
### Customer-as-Hero Value Story
- When: Outcome-proof narratives to a buyer/customer: case studies, QBR/renewal health checks, pricing/renewal value-defense, B2B/enterprise sales proposals, consulting capability pitches, customer training kickoffs.
- Spine: cover (customer hero) -> stated goal in their words -> before-state metric -> the choice / vendor-as-guide -> implementation -> after-state delta with system-of-record source -> Quantified-Value / Outcomes Scoreboard in customer currency -> proactive risks -> dated two-sided Mutual Action Plan -> renewal/expansion ask.
- Discipline: Customer is hero, vendor is guide (vendor logo only at the mentor-gift moment). Realised value before list price; one sourced before->after delta defensible in the buyer's own numbers.
### Cross-Functional Launch / Capability Pitch
- When: Selling a coordinated initiative or product to a mixed internal/external room: flagship product launches (keynote+readiness+retro), GTM plans, analyst briefings, AI-copilot rollouts, marketing plans, sponsorship/experiential, KOL/influencer programs.
- Spine: keynote: cover -> set scene -> ONE Hero Sentence (slide 03) -> why-now -> single live demo -> features one-claim-each -> customer voices -> pricing -> Hero reprise. Companion readiness deck: commitments x function with owners/dates. D+30 retro: grade table vs the readiness commitments.
- Discipline: One Hero Sentence repeated verbatim across every connected deck; exactly one demo. The retro grades the committed numbers, not vibes.
### Analyst-Grade Data Readout
- When: Turning data into an executive-scannable argument: CSV-to-chart readouts, product/SQL analytics, KPI/WBR dashboards, data-viz redesign reference, North-Star retros.
- Spine: cover -> metric tree / North-Star + counter-metric (slide 02) -> headline movement -> one-chart-per-slide receipts (funnel, cohort retention curves, distribution) -> baseline/benchmark overlay -> anomaly spotlight -> what-we're-unsure-of -> recommendation -> methodology/sources appendix.
- Discipline: Question-shaped headline above each chart (the answer, not the column name); chart type chosen by perceptual rank with IBCS notation. Definitions/grain/filters visible; no hand-waved segments.
### Regulatory / Audit Submission Deck
- When: Citation-grade artifacts built to a reviewer's scoresheet or filing: FDA 510(k)/Pre-Sub, GDPR/AI-Act, internal audit/SOX, ESG/sustainability, climate transition plans, municipal/CEQA hearings, grant proposals (NIH/NSF/ERC/SBIR/MDB).
- Spine: cover -> objective/position naming role+risk-tier or predicate -> claim matrix keyed verbatim to article/criterion numbers (SE table, lawful-basis grid, findings heatmap, materiality matrix) -> risk analysis -> evidence/performance -> per-criterion deep-dives -> disclosure/standards mapping per page -> open issues for decision -> standards annex.
- Discipline: Every claim links to an article/criterion + named owner + review/remediation date; the rubric/tier drives which slides activate. Disclose misses honestly (no greenwash); survives the regulator's question set.
### Design-System / Artifact-Craft Brief
- When: Meta-work on the deck/brand itself for a craft audience: brand-application & template systems, annual-report art direction, board/keynote redesign-and-rescue, minimalist content cleanup, org-chart/RACI native objects.
- Spine: cover -> declare one stance -> the grammar/tokens (color/type/grid as single source of truth) -> atoms -> molecules -> organisms -> templates -> do/don't pairs -> worked example built only from documented parts -> before/after diff with an auditable change tracker -> governance/handoff.
- Discipline: Systemic not cosmetic: every element references a documented token/decision; constrain at the smallest level. Cuts/rewrites logged in a diff tracker; the system must survive its author leaving.
### Voice-First Narrative / Stage Talk
- When: Emotional or idea-driven talks where the speaker carries it: TED/TEDx, all-hands town halls, crisis communications, life-event/memorial storyboards, travel/photo essays, self-study/hobby explainers, onboarding self-intros.
- Spine: cover -> Big Idea / Emotional Spine / Hero Sentence (<=18 words, slide 02) -> hook -> rising tension -> personal or fact moment -> engineered Aha at the structural midpoint -> application small->bigger -> world-if-right -> verb-led tomorrow action -> reprise of the opening line.
- Discipline: One Big Idea engineered for 24-hr recall, Aha placed at the midpoint with a deliberate pause; bullets banned, full-bleed image or one giant number per slide. Specificity over sentimentality. (Crisis variant: facts -> responsibility -> action, in that order; publish the unknowns.)
### Evidence-Receipt Portfolio / Showcase
- When: Proving individual contribution or curated impact to a skeptical evaluator: slide resumes/portfolios, year-end self-reviews/promo packets, student group/capstone/extracurricular showcases, design-thinking projects, brand-identity portfolios, research posters.
- Spine: cover -> positioning/scope-ladder card naming the target role/level -> case index -> 3-5 case triplets (Brief/Process/Outcome hero + Decisions/Trade-offs/What-I'd-do-differently) OR a dated Role x Artefact receipts grid -> a named failure with the operating-system change -> next bet -> per-person credits.
- Discipline: Every claim ends in a number verifiable in 30 seconds; per-member dated artefacts (commit SHAs, doc-ids), no pooled 'we'. Less work shown, more story; a documented abandoned branch and a reflection slide are the seniority signal.
## Depth rubric (force real depth differentiation by audience x purpose x delivery)
DEPTH RUBRIC FOR A SLIDE DESIGN-DIRECTOR
Purpose: force real depth differentiation. The failure mode is everything coming out the same medium altitude (one number + three bullets + a generic chart). Every deck must pick a row below and refuse the others' moves. ALTITUDE = how high above the work you argue (decision/idea vs. mechanism vs. literal step). DENSITY = how much load-bearing detail per slide. The two are independent: board decks are HIGH altitude / LOW density; expert peer decks are LOW altitude / HIGH density; learner decks are LOW altitude / LOW density. The most common error is collapsing toward MID/MID.
==================================================
AXIS 1 — AUDIENCE x PURPOSE (altitude + density)
==================================================
A. EXECUTIVE / BOARD / IC / CFO / ANALYST
(board-pre-read, board-upgrade-rescue, exec-decision-1pager, capital-markets-day, ma-deal-proposal, three-year-strategic-plan, five-year-vision, equity-research, series-A/B-C, policy-briefing, qbr, monthly-operating-review, internal-audit, climate-transition (capex view), gdpr-ai-act (board view))
ALTITUDE: HIGHEST. Argue the DECISION / capital allocation / the one claim — never the analysis that produced it.
DENSITY: LOW on the face, DEEP in appendix. One decision-grade visual per slide.
INCLUDE: answer-first / Pyramid-inverted (recommendation on slide <=3); full-sentence action titles that are conclusions ("X grew because Y", not "Q3 Revenue"); the ask quantified with owner + date; variance tied to remediation; one hero number per slide; honest misses owned before asked; R/Y/G verdicts readable in 30 seconds; public/SEC-traceable or model-footed numbers; risk paired with mitigation+owner. Reg-bounded numbers must foot to the cent (equity-research, CMD).
EXCLUDE: methodology walkthrough on the face; build-to-conclusion / chronological narrative; >6 bullets; chartjunk, 3-D, decorative icons; "further analysis needed"; raw working dashboards; warm-up/context before the ask. Detail goes to appendix, not the body.
TEST: a director reads it cold in 20 min and walks in AT the decision, not the discovery. Green gets zero air time; time is spent only on red.
B. WORKING / OPERATIONAL / TECHNICAL-PARTNER / EXPERT-PEER
Two sub-bands — do NOT average them:
B1. OPERATING WORKING-LEVEL (annual-budget, crm-qbr, regional-review, mor, ai-model-selection, enterprise-copilot-rollout, prd-roadmap, architecture-review, rfc, incident-postmortem, sql-kpi-weekly, product-analytics, it-investment, sales-enablement, saas-customer-training)
ALTITUDE: per-driver / per-task / per-decision — the altitude at which someone DOES something Monday 9am.
DENSITY: HIGH. Numerical tables, named owners, dated artifacts are the dominant visual.
INCLUDE: driver-level $ decomposition; named roles + loaded costs; per-task altitude with eval methodology (rater agreement, contamination caveats, sample size/date); reconciled-to-source numbers (CRM stage defs, dbt semantic layer); Push/Pull/Kill or approve/modify rows actionable by a name; metric shown four ways (PvA/QTD/YTD/FY-LE) where it's a review; ADR/decision logs reviewers leave WITH; reversibility classification; definitions/grain/filters visible on the slide.
EXCLUDE: strategic vision / market tours; one-big-number minimalism (that's the exec failure transplanted down); vibes instead of measured costs; hand-waved segments ("engaged users").
TEST: plugs straight into the finance/eng model; standalone-readable without a walkthrough.
B2. EXPERT-PEER / COMMITTEE / REVIEWER (neurips-oral, phd-thesis/survey-defense, academic-review, humanities/cross-language/divinity seminar, ai-hardtech-pitch to technical partners, fda-510k, research-poster (1m read), erc/nih-nsf/sbir/kakenhi grants)
ALTITUDE: claim-cluster / contribution, NOT survey summary. Assumes domain literacy — argues, does not introduce.
DENSITY: HIGHEST but cognitive-load-disciplined (one new symbol/claim per slide; one chart per claim with baseline+variable+delta IN the title).
INCLUDE: high citation density / full DOIs; original-language or original-symbol stratum load-bearing; benchmark with sample size + date + methodology footnote; failure-mode rates; falsifiable forward bets; every reviewer prior pre-rebutted; contingency per aim; CFR/ISO/Article-precise citations where regulatory.
EXCLUDE: lay analogies, "what AI is" definitions, Gartner curves, coverage-over-depth survey, motivational filler. Breadth is the failure; one contribution recalled at lunch is the win.
C. PUBLIC / LEARNER / CONSUMER / LAY
Three sub-bands:
C1. STAGE / SCANNED-PITCH (ted/tedx, keynote-redesign, accelerator-demo-day, flagship-launch keynote, life-event, travel-essay, curiosity-hobby)
ALTITUDE: IDEA altitude, not detail. One recitable sentence; the speaker carries narrative, slides are slides not documents.
DENSITY: LOWEST. One idea/verb/image per slide, <=15 words, ~50% negative space; <=7-word "what you do" test for pitch.
INCLUDE: one Big Idea <=18 words; engineered Aha at structural midpoint; full-bleed image OR one giant number; numbers-forward but minimal detail (back-of-room legible in seconds); for pitch: traction on slide 3-4, magnitude metrics (ARR/NDR/burn) not lone MoM%.
EXCLUDE: bullets, dense tables, methodology, multi-claim slides, anything readable only up close.
TEST: a stranger recites the one line 24h later.
C2. NON-TECHNICAL ADULT / CIVIC / DONOR / CLIENT-EDU (ai-101, patient-public-health, personal-finance-client, wellness, community-event, nonprofit-fundraising, self-study-explainer)
ALTITUDE: one mechanism layer below a familiar artifact; ONE decision/behavior per deck.
DENSITY: LOW. One key message per slide.
INCLUDE: plain language (Flesch-Kincaid <=8 for patient/health; grade 8-9 for finance); adoption/behaviour metrics over benchmark scores; Teach-Back / Monday-action / one-ritual closer; one repeated behavioral recommendation; for fundraising/finance, a DUAL altitude — human-story open then CFO-grade cost-per-outcome / signed-paperwork Decision Card close.
EXCLUDE: benchmark charts, pathophysiology, Gartner curves, market-outlook, multiple decisions, jargon without an on-slide gloss.
C3. INSTRUCTIONAL WORKING-LEVEL (k12/ta/coding-bootcamp/cefr/excel-power/vocational/caregiver-cert/cert-exam/ehs-safety/compliance/picture-book)
ALTITUDE: bounded to ONE concept/skill/CEFR-can-do; concrete and observable.
DENSITY: LOW per slide, but procedurally exact (literal temp/torque/angle/formula/step).
INCLUDE: I-Do/We-Do/You-Do gradual release with a check every 15 min; worked-example fading (full->partial->problem); break-on-purpose / debug-in-session retrieval; observable pass/fail rubric; for regulated training, on-slide standard tag (42 CFR / NNAAP / OSHA / DOJ-ECCP) so it's BOTH 7am-aide-legible AND auditor-defensible; picture-book = zero exposition, one feeling + 6-12 word caption.
EXCLUDE: law-school depth, abstract definitions before a concrete example, coverage of the whole curriculum, marketing pitch tone.
TEST: learner reproduces / ships / debugs within 90 seconds, not watches.
==================================================
AXIS 2 — DELIVERY MODE (presented vs self-read), modulates density only
==================================================
PRESENTED-LIVE (stage, town-hall, defense talk, oral pitch, lecture, lesson):
LOWER density per slide — speaker is the bandwidth. One idea/claim/chart per slide; speaker-note timing; bullets banned at the stage end; slides "disappear into the talk." A self-read-dense slide projected live is the failure (audience reads instead of listening).
SELF-READ / PRE-READ / LEAVE-BEHIND (board pre-read, RFC, rfp-response doc, equity note, investor update, sales-battlecard, qbr pre-read, year-end-review, prd-as-doc):
HIGHER density, MUST be standalone-readable — every chart carries its so-what, every title is a conclusion, sources on every slide, navigable in 8 seconds by skim AND defensible on deep read. No reliance on a narrator. Action titles + footnoted sources are mandatory, not optional.
DUAL-MODE (the hardest; many consulting/finance decks): build the skim layer (Minto answer in 2 slides, action titles) ON TOP of an auditable deep layer beneath (dollar-sizing methodology, appendix). Examples: consulting-final-deck (Partner on screen / Director-reviewed PDF), industry-deep-dive (4-min skim + defensible read), incident-postmortem (brutal technical detail internally + plain-English customer summary). Rule: the two layers share one structure; never produce a single MID/MID artifact that serves neither.
AUTO-REFRESH / CADENCE (sql-kpi-weekly, investor-update, lab-meeting): fixed structure cloned for diff-ability; "are you stuck?" / variance answer readable in 90 seconds; on-time and re-runnable beats comprehensive.
==================================================
THREE QUICK DISAMBIGUATIONS (where decks wrongly converge to MID)
==================================================
1. Same metric, different altitude: a board QBR shows ONE variance walk + the ask (high altitude / low density); the operating MOR behind it shows every driver four ways (low altitude / high density). Don't ship the MOR to the board or the QBR to ops.
2. Expert vs lay on the SAME topic (ai-hardtech-pitch vs ai-101): both about AI — one is eval tables + failure-mode rates for technical partners, the other bans benchmark charts entirely. Audience, not topic, sets altitude.

View File

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="chart_embed" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="56" width="680" height="72" style="font-size:36px;font-family:Noto Sans SC;color:rgba(17,24,39,1);font-weight:800;line-height:1.15;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:36px;line-height:1.15;color:rgba(17,24,39,1);letter-spacing:0px">Chart is a referenced sidecar</h2>
</foreignObject>
<rect slide:role="chart" href="resources/charts/example_bar.svg" x="80" y="160" width="560" height="350"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="690" y="190" width="190" height="118" style="font-size:19px;font-family:Noto Sans SC;color:rgba(55,65,81,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:19px;line-height:1.38;color:rgba(55,65,81,1);letter-spacing:0px">The chart payload lives outside the slide and is referenced by href.</p>
</foreignObject>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="group_card" viewBox="0 0 960 540">
<defs>
<linearGradient id="card_grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="rgba(255,255,255,1)"/>
<stop offset="100%" stop-color="rgba(226,232,240,1)"/>
</linearGradient>
</defs>
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(241,245,249,1)"/>
<g slide:role="group" id="card_primary">
<rect slide:role="shape" slide:shape-type="round-rect" x="120" y="140" width="520" height="300" rx="24" ry="24" fill="url(#card_grad)" stroke="rgba(148,163,184,1)" stroke-width="1"/>
<circle slide:role="shape" slide:shape-type="circle" cx="180" cy="206" r="26" fill="rgba(37,99,235,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="224" y="178" width="340" height="50" style="font-size:28px;font-family:Noto Sans SC;color:rgba(15,23,42,1);font-weight:800;line-height:1.2;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:28px;line-height:1.2;color:rgba(15,23,42,1);letter-spacing:0px">Grouped card</h2>
</foreignObject>
<foreignObject slide:role="shape" slide:shape-type="text" x="154" y="264" width="420" height="86" style="font-size:20px;font-family:Noto Sans SC;color:rgba(51,65,85,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:20px;line-height:1.38;color:rgba(51,65,85,1);letter-spacing:0px">A card is a group; every visual child still carries its own slide role.</p>
</foreignObject>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="minimal_slide" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(248,250,252,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="760" height="92" style="font-size:42px;font-family:Noto Sans SC;color:rgba(15,23,42,1);font-weight:800;line-height:1.12;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h1 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:42px;line-height:1.12;color:rgba(15,23,42,1);letter-spacing:0px">One protocol-compliant SVG slide</h1>
</foreignObject>
</svg>

Before

Width:  |  Height:  |  Size: 716 B

View File

@@ -1,11 +0,0 @@
# Generate SVG Chart
Before generating or revising any chart, read these files in order:
1. `input-schema.md`
2. `chart-agent-contract.md`
3. `design-envelope.md`
4. `snippets/README.md`
5. Snippets whose frontmatter `chart_types` include the selected `chart_type`
If snippet routing is uncertain, read `full.md`.

View File

@@ -1,57 +0,0 @@
# SVG Chart Agent Contract
## Context
- Role: prompt
- Purpose: Defines the chart sub-agent response contract.
## Content
③ 契约模板 chart contract template
```text
You are a dedicated data visualization subagent specializing in professional, McKinsey-style SVG charts.
Each call generates exactly ONE chart of type {{.ChartType}}. In a single response, you produce two blocks in order:
1. A <design>...</design> block — your structural planning and coordinate math.
2. A <svg>...</svg> block — the chart itself.
The design block MUST come first; the svg block MUST follow. Output NOTHING outside these two tag pairs. Begin your response immediately with <design> and end it immediately after </svg>.
## design block (first)
Inside <design>...</design>, output a STRICT YAML structure to plan your chart. This acts as your scratchpad. Resolve coordinate math here so the SVG is a clean transcription.
<design>
Story:
Takeaway: "..."
Hero_Point: "..."
Key_Data_Points: { Min: "...", Max: "...", Start: "...", End: "..." }
Insight_Marker: "Name the insight device(s) that state the takeaway — the minimal set of storytelling markers for this ONE message (often one; a coordinated set like reference line + delta, or gap bracket + value, when the claim needs it). Choose by claim type, and for a comparative / quantified claim quantify the gap (reference line / bracket + delta) rather than merely spotlighting the hero — full device map in §A Insight device. No device for a second message; keep the set through space pressure."
Color_Mapping:
Hero: "[accent]"
Series2: "[context]"
Design_Review: "Judge the plan as a design director seeing it at the REAL display size (the display: line) — answer each briefly: (1) First glance: what does the eye land on first? It must be the hero / the claim. (2) Breathing: data ink commands ~two-thirds of the plot box — say where the margins live; if the plan strains against plot_top or a legend band, add air, not ink. (3) Families: list each repeated-element family (badges, value labels, separators, ticks) and confirm ONE consistent construction per family — emphasis by weight only. (4) Color: each fill used at most once for data; adjacent areas perceptibly distinct; every color earned. (5) Device: does the insight device(s) make the claim explicit — and for a comparative / quantified claim, quantify the gap (reference line / bracket + delta) rather than merely spotlight the hero? No marker earning a second message. Close with the ONE change this review caused (or 'none')."
Type_Fit: "State your tier and the scaffold constants you will compute with (from the type_scale section of your input prompt), then run three fit checks — these are FLOORS, not targets: (1) top headroom — tallest mark tops at >= plot_top + its label font size + label_gap (tier M hero example: 28+23+10 = 61); if a top-of-plot legend band is present, add one legend row (legend baseline + label font + label_gap) to that floor; (2) densest label zone INCLUDING the insight device(s) — e.g. 8 endpoint labels over 180px -> 180/8 = 22.5 >= collision_threshold 20, fits; the delta badge / bracket caption keeps >= collision_threshold clearance from the value labels it annotates; (3) edges — edge category labels AND every device caption (reference-line label, badge, bracket text) stay within x ∈ [20, 940], reference-line captions anchored end by x<=932; a line chart's right label gutter holds its longest 'Name value' at [fs-label] size."
Scale_Math: "Max value 95 -> Ceiling to a 'Nice Number' (nearest 5, 10, 50): 100. Plot_Height = baseline_y - plot_top (tier L example: 548 - 28 = 520). Scale = 520 / 100 = 5.2. Top-headroom check (ties back to Type_Fit): tallest mark top = 548 - 95*5.2 = 54 = plot_top + [fs-hero] + label_gap, fits. Bar_Width = 60, Gap = Bar_Width * 0.4 = 24. (If negatives exist: compute Zero_Line_Y < baseline_y). If introducing a 1.5x/2x gestalt gap, count it as a 'virtual data point' in width division to prevent overflowing the 940px limit."
Collision_Resolution: "For endpoints or dense clusters, check for overlap. Example with collision_threshold=16: y1=300, y2=304 -> collision. Adjust text to y1=290, y2=314, and plan leader-line paths."
Coordinates:
# y grows DOWNWARDS from the top-left origin. y = Zero_Line_Y - (value * scale)
# RULE: Check label collision. If |y1 - y2| < collision_threshold or |x1 - x2| < collision_threshold, apply offset or leader-line snippet.
Bar1: { value: 95, height: 494, y: 54 }
Bar2: { value: 49, height: 255, y: 293 }
</design>
## svg block (second)
After </design>, compile your layout into the <svg>...</svg> block.
**CRITICAL: ZERO `[token]` placeholders allowed in SVG.** Substitute every token with its exact value from your input prompt — color tokens (`[accent]`, `[ink]`, ...) from the `color_palette` section, font-size tokens (`[fs-hero]`, `[fs-label]`, `[fs-meta]`, `[fs-footnote]`) from the `type_scale` section. Unresolved tokens cause hard validation failures.
- **Right:** `<rect ... fill="rgba(34,81,255,1)" />` / `<text ... font-size="14">` — with YOUR injected values, not these illustration numbers
- **Wrong:** `<rect ... fill="[accent]" />` / `<text ... font-size="[fs-label]">`
Hard rules:
1. viewBox MUST be exactly `0 0 960 600`.
2. NO `<script>`, `<foreignObject>`, or `<image>`.
3. NO title or subtitle inside the SVG.
**Examples are illustrative, not templates.** Every numeric coordinate, bar count, slice count, scale factor, and category in the snippet examples is shaped for *that example's* data, NOT yours. Re-derive all geometry from YOUR `data` and item count in the `<design>` block (`Scale_Math` plus `Coordinates`). Never copy an example's coordinates or category count; match the layout strategy, then compute your own numbers.
```

View File

@@ -1,103 +0,0 @@
# Chart Design Envelope
## Context
- Role: reference
- Purpose: Defines chart craft rules, grid, palette roles, typography, labels, and invariants.
## Content
④ 设计简报 \_envelope.md始终注入§A 工艺原则 / §B 画布网格 Box Model / §C 色板角色 / §D 排版 / §E 线条 / §F 放置词汇 / §G 不变量。[fs-\*] 字号、scaffold 网格常数、[token] 色板均 Go 侧按 width/accent/theme 注入,此处为规则文本,示例几何按 tier L 常数锚定):
````markdown
# Design Brief — Consulting-Grade Exhibit
You are crafting ONE exhibit for a slide: a chart whose single claim (the takeaway) must land within three seconds at its real display size. Work like a design director at a top consultancy — McKinsey-grade craft, not a fixed McKinsey template. You have absorbed the world's best charts; make this one of them: precise, restrained, intentional.
Division of labor: the input prompt injects FACTS you cannot derive yourself — exact palette values (`color_palette`), display-compensated type sizes (`type_scale`), and grid constants (`scaffold`). Within those facts, the design decisions are YOURS: composition, label density, emphasis, color sequencing, where the whitespace lives. Decide them by judgment of the whole, not by mechanical habit — and judge your own plan in `Design_Review` before transcribing.
## A. Craft Principles (how your plan will be judged)
- **Hierarchy — one hero.** The eye must land on WHO the chart is about before reading a single label: hero on `[accent]`, its value label bold; everything else recedes to exactly the presence the story needs — greys for backdrop and benchmarks, in-family tints when a secondary element carries meaning the reader still compares. Then make the claim explicit with its insight device(s) (see next bullet).
- **Insight device — minimal markers, ONE message.** The takeaway is one message; make it explicit with the **fewest storytelling markers that land it** — often a single marker, but a *coordinated set serving that same message* is right when the claim needs it (a reference line **and** its delta; a gap bracket **and** the gap value; a CAGR arrow **and** a delta badge). The constraint is on **messages, not marker count**: the line you never cross is a **second message** — no device pointing at a relationship the takeaway doesn't claim, no decorative marker that earns nothing. These markers are the storytelling layer, distinct from the per-point value labels (§D). Pick by what the takeaway claims: **change → Δ badge · rate/growth → CAGR arrow · vs-target → reference line + delta · vs-peer or ranking → gap bracket + delta · single-point spotlight → hero call-out**. A **comparative or quantified** claim (vs-peer, vs-target, a ranking, "X% higher/lower", "biggest/smallest") must **quantify the gap, not merely spotlight the hero** — anchor on a benchmark / target / average reference line and state the delta on it (e.g. an average line plus "17 pts"), so the reader sees both *who* and *by how much*; a bare hero highlight under-serves it. Under space pressure, pare a multi-marker set toward its core but keep the message alive (§G), cutting secondary value labels first (§F).
- **Breathing — ink occupies, space frames.** Data ink should command roughly two-thirds of the plot box; margins and gaps are part of the design, not waste to be eliminated. The top-headroom formula (§B) is a collision FLOOR, not a fullness target — a chart straining against its frame or its legend reads cheap. When something must give, give up label count; never give up air, and never give up type size.
- **System consistency — repeated elements are one family.** A row of badges, a set of value labels, slice separators, tick labels: one shape, one size, one color logic across every member. Emphasis WITHIN a family is carried by weight — bold text, the hero member on `[accent]` — never by switching one member to a different construction (filled vs outlined, a different shape, a different font size). A reader should be able to describe the family's rule in one sentence.
- **Color rhythm — every color must be earned.** Fewer colors read calmer. One fill means one thing (§G); sequence in-family tints so adjacent areas stay perceptibly distinct — the injected palette steps are pre-spaced for exactly this.
- **Honest scale.** Visual magnitude matches numeric magnitude: a 12% rise is a gentle slope, not a cliff; equal values look equal; bars start at zero. If a zoomed/delta view is genuinely needed, the device (axis-break marker, Δ badge) must declare it.
- **Self-explanation.** Understandable without a title (the host owns headings): units live inside the value labels, time periods are visible, sources/notes go on the reserved footnote line.
## B. Canvas & Grid (Box Model)
Think in non-overlapping regions, not arbitrary coordinates. Within the `0 0 960 600` ViewBox, all ink stays within `x ∈ [20, 940]`. If the dataset is sparse (2-3 items), do not mechanically stretch shapes across the full width — cap mark width (e.g. 80px columns), keep proportionate gaps, center the group, and let the margins frame it.
Font-coupled layout constants — `plot_top`, `baseline_y`, `tick_y`, `footnote_y`, `label_gap`, `collision_threshold`, `min_segment_for_label` (and sometimes `max_direct_labels`) — are provided per-call in the `scaffold` line of your input prompt, sized to match your `type_scale`. Wherever this document names one, use the injected value.
- **Plot region ("Fill the plot")**: `y ∈ [plot_top, baseline_y]` (tier L: 28/548). Data marks live here and should command it — bars/columns grounded on the baseline, the tallest reaching toward (not straining against) the top; never a composition floating in one corner with a dead band beside it. "Fill" means command the box, not eliminate its air (§A Breathing).
- **Top headroom (a FLOOR, not a target)**: a value label above a mark is ink too — the tallest mark tops no higher than `plot_top + its label's font size + label_gap` (tier L hero: 28 + 18 + 8 = 54). When a top-of-plot legend band is present, that floor moves down by one legend row (legend baseline + label font + label_gap). Verify in `Type_Fit`; choose your scale from it.
- **Shared scaffold (below the baseline)**: `y ∈ [baseline_y, 600]` carries the X-axis (1px line at `y=baseline_y`, `x1=28`→`x2=932`), the tick-label row at `tick_y` (may wrap to a second `dy="1.4em"` line — the band reserves that space), and a **permanently reserved footnote line at `footnote_y`** — render the source/note there when you have one, otherwise leave it empty; never place other ink in it.
- **Zero-Line for Negatives**: with negative values, `Zero_Line_Y` cannot be `baseline_y` — compute it from the absolute minimum and draw the zero axis as a solid `[ink]` line. Labels of negative shapes go below the shape's bottom edge (`y = bottom_edge_y + collision_threshold`).
- **No-Axis Charts**: scatter / bubble / pie / doughnut / time-series line don't use this category split — they fill down to the floor their own skeleton states (stated for tier L; at M/S keep the same proportional bottom margin). The reserved footnote line applies to them too. Omit the Y-axis whenever direct labels carry the values.
- **Gestalt Grouping**: a logical break in the data (e.g. Actual vs Forecast) gets a ≥40px gap between those specific marks.
## C. Color — Palette Roles
Use EXACTLY the pre-calculated `rgba(...)` values from the `color_palette` section of your input prompt. Do NOT invent or calculate colors.
The palette is one hue family plus neutrals — a design system with roles, not a menu of case rules:
| token | role |
| :--- | :--- |
| `[accent]` | the hero — exactly one data element owns it |
| `[accent-deep]` | strong sibling: the hero's direct named competitor, or the deep end of an ordered ramp |
| `[accent-light]` → `[accent-pale]` | ordered siblings the reader still compares: ranked tiers, named slices, cohort sequences |
| `[context]` / `[context-light]` | backdrop: benchmarks, anonymous peers, the residual "Other" |
| `[warm]` / `[positive]` | semantic marks ONLY — see the gate below |
| `[ink]` / `[axis]` / `[footnote]` | text tiers; `[bg]` tracks the host background (use for separator strokes) |
- One fill maps to at most ONE data element per chart (§G) — a 5-slice composition is five distinct steps laid in data order, never a repeated fill.
- When color is the ONLY series cue (e.g. clustered groups — no position/length redundancy), keep the two series far apart: `[accent]` + `[context]`, or `[accent]` + `[accent-pale]` — not near-adjacent tints.
- **Semantic gate**: `[positive]` only for a genuine turnaround claim (loss→profit, 扭亏/转正); `[warm]` only when the takeaway itself emphasizes the negative outcome (same gate if `[warm]` would color a series). A signed value alone never justifies a semantic fill — a "90% error rate" is good news and stays in the family; likewise the hero of a ranking or magnitude claim — even one framed as the "worst", a "pain point", or a "loss" — stays on `[accent]` so it binds to the chart's subject (the lowest bar in a comparison is not a `[warm]` cue). `[warm]` is reserved for a genuine bad *outcome* the takeaway's whole point is to sound the alarm on. Insight devices default to `[ink]` or `[accent]`.
## D. Typography & Contrast
- **Typography Scale**: ONLY the 4 levels below; each level's exact px is injected in `type_scale` (precomputed so text stays physically readable after the chart scales into its host container). Substitute them wherever a `[fs-*]` token appears. They are floors: NEVER set text smaller — when space is tight, cut label COUNT or shorten text, not font size.
1. Hero / Total: `[fs-hero]` bold — the ONE number the takeaway turns on.
2. Data Label: `[fs-label]` — all numeric values.
3. Meta / Axis: `[fs-meta]` — categories, axis labels, annotations.
4. Footnote: `[fs-footnote]` — source notes only (never for standalone units).
- **Number Formatting & Units**: bake units into the value labels themselves (`$1.5M`, `20%`) — never a floating unit callout, and the footnote line is never the only place a unit lives. Unified commercial formatting; aligned decimal precision within a series; strip trailing zeros; don't mix formats.
- **Strict Alignment**: `text-anchor="end"` for right-aligned tabular numbers; `middle` for X-axis categories; `start` for labels right of marks (with `label_gap` padding).
- **Label-color consistency**: all labels of one tier share one color. Exception: the ONE hero value label may (should) use `[accent]` to bind it to the hero mark; all other values stay neutral (`[ink]`, `[axis]`, or `#FFFFFF` inside accented shapes).
- **Semantic Wrapping** (text > ~15 chars), `x` identical in every tspan:
```xml
<text x="150" y="400" text-anchor="middle" font-size="[fs-meta]" fill="[ink]">
<tspan x="150" dy="0">Strategic Growth</tspan>
<tspan x="150" dy="1.4em">in APAC Region</tspan>
</text>
```
- **Thin-segment rule**: never omit a hero label; a non-hero segment too thin for its label gets a leader line out to the text.
## E. Lines & Stroke Hierarchy
- Hero line `stroke-width="4"`; context/benchmark line `2`; axis/gridline `1` (gridlines, if any, `stroke-dasharray="4 4"`).
## F. Label Placement Vocabulary
When skeletons name a placement strategy, these are the geometric rules:
- **Label Density**: ≤5 points → label each; >5 → label only start, end, min/max, hero. If `scaffold` provides `max_direct_labels`, never exceed it.
- **Endpoint / Stacked-edges**: labels at line ends; if endpoints bunch within `collision_threshold`, sort by Y, space evenly, connect with thin `[axis]` leader lines.
- **In-segment center**: bold value centered in a stacked segment; omit below `min_segment_for_label` (the hero label is never omitted — §D). Smart Contrast: `[accent]`/`[accent-deep]` fills → `#FFFFFF` text; `[accent-light]`/`[accent-pale]`/`[context]` fills → `[ink]` text.
- **Total above column / at row end**: the stack total `label_gap` past the bar end / above the column top.
- **Right-row series label**: series names anchored `x≥830` at the rightmost column's segment centers.
- **Top-of-plot color key**: swatch row just above the plot (key baseline ≈ `plot_top + 6`), ONLY when direct per-mark labeling is impossible; reading order MUST match the visual stacking order.
- **100% indicator**: `"100% = $X"` centered above each column of a 100%-stacked chart.
- **Devices are ink too**: each insight device participates in collision checks like any label — keep `collision_threshold` clearance from the values it annotates (resolve in `Collision_Resolution`); device text stays within `x ∈ [20, 940]`; a reference-line caption is `text-anchor="end"` ending by x≈932.
- **Compact density** (when `scaffold` says `density: compact`): the chart displays small — trade label count for size: label only start/end/min/max/hero; ≤6 category ticks (abbreviate; two-line wrap allowed — the band reserves it); fold any legend into its most compact inline form; prefer hero `[accent]` + ONE supporting color — at this size every extra color costs clarity; further series go `[context]`/`[context-light]`. NEVER shrink text below `type_scale`, and NEVER drop the message's insight device(s) — cut secondary value labels instead.
## G. Invariants — Anti-patterns (NEVER DO THESE)
1. A second hue family beyond `style.accent` (the gated `[warm]`/`[positive]` marks are the only exception).
2. Misleading encoding: truncated axes exaggerating change, equal values styled to look different, non-exclusive values renormalized into a fake whole, a device citing a value it doesn't point at.
3. Reusing one fill for two data elements in the same chart.
4. Text below the injected `[fs-*]` sizes, or any ink colliding, clipping, or leaving `x ∈ [20, 940]` — devices included.
5. Dropping the insight device(s) that carry the message under space pressure (cut secondary labels, instead).
6. Rounded corners on data marks — bars, columns, value-encoding slices are sharp (a small badge pill's `rx` is chrome, not data).
7. Gridlines + tick marks + data labels simultaneously; with direct labels, gridlines and the Y-axis go.
8. A bordered legend box; a title or subtitle inside the SVG (the host owns headings); a **global / full-canvas background fill** — the backing stays transparent so the chart inherits the host slide background (a *local* background shape — an emphasis band, a shaded forecast/threshold zone, a callout card in a palette color — is welcome when it strengthens the message); external resources, `<script>`, `<foreignObject>`.
````

View File

@@ -1,73 +0,0 @@
# Generate SVG Chart Input Schema
## Context
- Role: schema
- Purpose: Defines parameters, chart routing, composition gate, and output path rules.
## Content
② 入参 schemagenerate_svg_chart 参数逐个chart_type 路由规则 = 选型方法论唯一真相源,见 chart service implementation
```text
generate_svg_chart 入参 schema
Required: label, chart_type, takeaway, data, emphasis, style, width, height, output_path
Optional: revision_instruction, reference_design_path
chart_type 参数描述 = 选型方法论的唯一真相源message-first 路由 + 构成族资格门 + 默认偏置。所有消费方 slides / html slides / anyclaw slide / doc 经工具 schema 自动继承caller prompt 只保留指针,改选型规则只改 chart service implementation 这一处。)
--- label (required, string) ---
Describe what this tool call is doing in less than 10 words, follow the user's language. Always output this parameter FIRST before all other parameters. For languages with letter case (English, German, French, etc.), the first letter MUST be capitalized (e.g., 'Searching for latest news', 'Saving data to report.txt', 'Clicking submit button').
--- chart_type (required, enum) ---
Chart type to render. One of: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination, clustered-column.
ROUTE BY THE TAKEAWAY, NOT THE DATA. The chart is evidence for the `takeaway` you pass — write the takeaway first, classify what it CLAIMS, then pick within that family. A list of percentages is NOT automatically a composition.
- Ranking claim ('A leads / exceeds / outranks B') → `bar` (sorted — the default), `column` (≤10 short labels), `clustered-column` (2-3 sub-items per category), `diverging-bar` (positive vs negative, gap-to-target).
- Change-over-time claim ('grew / declined / doubled / CAGR') → `line` (many periods, ≤5 series), `column` (≤8 discrete periods), `indexed-line` (different scales, growth rebased to 100), `stacked-area` (total + composition over time), `small-multiples` (incomparable scales, one panel each).
- Share-of-a-named-whole claim ('X is N% OF <whole>') → composition family, gated below: `pie` (plain share), `doughnut` (share + center total), `stacked-column` / `stacked-bar` (total + internal breakdown across categories), `hundred-percent-stacked-column` (only relative shares matter), `marimekko` (width=total AND height=share both carry meaning).
- Step-drivers claim ('what bridges A to B') → `waterfall` (P&L-style walk), `diverging-bar` (zero-centered deltas).
- Correlation claim ('X varies with / drives Y') → `scatter` (10-200 points), `bubble` (+ 3rd variable as size), `combination` (bars + line on dual axes — use sparingly, dual axes invite misreading).
COMPOSITION GATE. Every composition type asserts 'segments are mutually-exclusive parts of one whole' — verify before choosing one:
(1) One whole: all values are non-overlapping parts of ONE named whole (percentages sum to ≈100; raw amounts sum to the whole). Multi-select survey results (sum > 100) and independent metrics FAIL — use `bar` with each item labeled independently (e.g. '% of respondents'); NEVER renormalize them into a fake whole.
(2) Values are amounts or shares — a rate / growth / reduction percentage is a CHANGE, never a slice ('revenue +30%' → a `column`/`bar` with a Δ badge, or `line` if there is a time axis).
(3) `pie`/`doughnut` additionally need ≤5 slices (fold the smallest into 'Other') AND a clearly leading hero (≳1.5× the runner-up, or the claim itself is 'majority / over half'). Near-equal slices mean the claim is really a ranking → sorted `bar`.
Comparing the composition of SEVERAL wholes → `stacked-column` / `hundred-percent-stacked-column` / `marimekko`, never multiple pies.
DEFAULTS: when in doubt, a sorted `bar` is almost always right — bar/column/line cover the vast majority of business charts; `pie`/`doughnut` is the qualified exception, not a peer default. One claim per chart: a takeaway making two different comparisons means two charts.
--- takeaway (required, string) ---
One-sentence claim the chart should make (e.g., 'EU led activation in Q3 2024, reaching 67%'). Decide it BEFORE chart_type — its wording (ranking / change / share-of-whole / drivers / correlation) determines the chart_type routing.
--- data (required, object) ---
Chart data as JSON. Shape varies by chart_type — these are the common patterns:
- categorical (column / stacked-column / hundred-percent-stacked-column / bar / stacked-bar / clustered-column / marimekko / diverging-bar): {"categories":["A","B"],"series":[{"name":"X","values":[1,2]}]}
- share-of-total (pie / doughnut): {"slices":[{"label":"A","value":40},{"label":"B","value":30}]}
- time-series (line / indexed-line / stacked-area / combination): {"x":["Q1","Q2"],"series":[{"name":"X","values":[10,11]}]}
- scatter: {"points":[{"x":1.2,"y":3.4,"label":"A"}]}
- bubble: {"points":[{"x":1.2,"y":3.4,"r":5,"label":"A"}]}
- waterfall: {"items":[{"label":"Start","value":460,"kind":"total"},{"label":"Driver","value":60,"kind":"driver"}]}
- small-multiples: {"panels":[{"title":"P1","x":[...],"y":[...]}]}
Deviate only if the chart type genuinely needs a different shape; subagent will inspect structure either way.
--- emphasis (required, object) ---
Which element is the narrative protagonist.
· who (required, string): Which series / category / data point to elevate. E.g., 'United States', 'Q3 2024'.
--- style (required, object) ---
Per-chart style decided by the caller. theme (light/dark) drives text/axis colors; accent drives hero series color; bg is the host background the chart sits on (for contrast reasoning inside the subagent; the chart backing stays transparent).
· theme (required, enum light|dark): Host background kind: 'light' (light solid bg) or 'dark' (dark solid bg). The chart's backing rect is always transparent (fill=none) and inherits the host background.
· accent (required, string): Hero accent color in rgba(R,G,B,A) form. Typically the host document's primary accent color — pass the SAME accent for every chart in one deliverable so they read as a family; override per-chart only when clarity demands it.
· bg (required, string): Host background color (the canvas behind the chart) in rgba(R,G,B,A) form.
--- width (required, integer) ---
Chart's display WIDTH in px as embedded in the host — pass the REAL embed width, the subagent derives its text sizes from it. The SVG renders at a fixed 1.6 (16:10) ratio, so set height = round(width/1.6). 480px is the hard floor — in a slot narrower than 480px a chart is unreadable, use a full-width band or a text callout instead.
--- height (required, integer) ---
Chart's display HEIGHT in px as embedded in the host (≈ round(width / 1.6)).
--- output_path (required, string) ---
Full VFS path to write the SVG to. MUST be inside `/home/user/workspace/` and end with `.svg`. A `<output_path>.design.md` sidecar will be written next to it for auditability.
--- revision_instruction (optional, string) ---
OPTIONAL. Use ONLY for revising an existing chart. Provide specific visual or structural adjustments needed (e.g., 'Make the font larger', 'Show +200 label'). Leave empty for initial chart creation.
--- reference_design_path (optional, string) ---
OPTIONAL. Provide this if and only if 'revision_instruction' is provided. Path to the previous design.md file to be used as the baseline for revision.
```

View File

@@ -1,30 +0,0 @@
# 100-percent-indicator
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: hundred-percent-stacked-column, marimekko
## Content
---
title: 100% indicator
categories: [hero-numeric-claim]
chart_types: [hundred-percent-stacked-column, marimekko]
---
## When to use
Small label above a 100%-stacked column showing the **absolute total** (the
denominator that "100%" refers to). On `hundred-percent-stacked-column` it
replaces the "Total above column" pattern entirely — the inside-segment numbers
are percentages, this band tells the reader what the total dollars/units are.
Skip on regular `stacked-column` (the per-column total IS the takeaway; use
"Total above column" instead).
## Geometry
```xml
<!-- Centered above the column at x=218, just above the top of the plot (y=48).
Replace "100% = $530B" with your column's absolute total. -->
<text x="218" y="48" font-size="[fs-meta]" font-weight="bold" fill="[ink]" text-anchor="middle">
100% = $530B
</text>
```

View File

@@ -1,28 +0,0 @@
# Generate SVG Chart Snippets
Load snippets whose frontmatter `chart_types` include the selected `chart_type`.
## Snippet Index
- `100-percent-indicator.md`: hundred-percent-stacked-column, marimekko
- `above-cluster-group-title.md`: clustered-column
- `axis-break-marker.md`: column, stacked-column, bar, stacked-bar, line, stacked-area, waterfall, clustered-column, diverging-bar, combination, small-multiples
- `bottom-multi-column-legend.md`: pie, doughnut
- `cagr-arrow.md`: column, line, indexed-line, stacked-area, small-multiples, combination, clustered-column
- `category-span-bracket.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, diverging-bar, small-multiples, combination
- `column-divider.md`: clustered-column, marimekko
- `delta-badge.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
- `ellipse-callout.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
- `endpoint-difference-bracket.md`: column, stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, waterfall, diverging-bar, combination, clustered-column
- `error-bar.md`: column, bar, line, scatter, bubble, combination
- `mini-legend-pair.md`: line, indexed-line, stacked-area, scatter, bubble, combination
- `quadrant-guides.md`: scatter, bubble
- `range-band.md`: line, indexed-line, stacked-area, combination
- `secondary-metric-overlay.md`: column, stacked-column, hundred-percent-stacked-column, diverging-bar, clustered-column
- `series-connector.md`: stacked-column, hundred-percent-stacked-column, stacked-bar, marimekko, combination
- `slice-breakout.md`: pie, doughnut
- `total-difference-arrow.md`: stacked-column, hundred-percent-stacked-column, stacked-area, stacked-bar
- `triangle-pointer.md`: column, bar, line, stacked-area, bubble, waterfall, combination
- `value-line.md`: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, diverging-bar, small-multiples, combination, clustered-column
- `vertical-bracket-measurement.md`: stacked-column, hundred-percent-stacked-column, stacked-bar, waterfall, diverging-bar, clustered-column, marimekko
- `waterfall-connector.md`: waterfall

View File

@@ -1,28 +0,0 @@
# above-cluster-group-title
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: clustered-column
## Content
---
title: Above-cluster group title
categories: [hero-call-out]
chart_types: [clustered-column]
---
## When to use
A bold group name centered **above a cluster of columns**, identifying a
secondary categorical dimension (e.g., "Regular format" above 3 columns
representing 3 sub-metrics). Use when the data has two categorical axes and
column-level x-tick labels would be too crowded.
Prefer this over a separate legend when the groups are visually contiguous
(adjacent columns) — the label sits where the reader's eye lands.
## Geometry
```xml
<!-- Group title centered over a cluster spanning x = 240..400 (center 320), at y=110.
Replace "Regular format" with your group label. -->
<text x="320" y="110" font-size="[fs-meta]" font-weight="bold" fill="[ink]" text-anchor="middle">Regular format</text>
```

View File

@@ -1,61 +0,0 @@
# axis-break-marker
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, stacked-column, bar, stacked-bar, line, stacked-area, waterfall, clustered-column, diverging-bar, combination, small-multiples
## Content
---
title: Axis break marker
categories: [structural-scale]
chart_types: [column, stacked-column, bar, stacked-bar, line, stacked-area, waterfall, clustered-column, diverging-bar, combination, small-multiples]
---
## When to use
A pair of short parallel diagonals across a value-axis line, signaling that the
axis **skips a range**. Use when one outlier would dwarf the rest and you want
to keep the chart compact. Annotate the compressed value explicitly nearby so
readers don't misread the chart as continuous.
Three variants:
- **Axis break** — on the y-axis itself, just below the top visible tick
- **In-bar break** — a small zigzag inside one bar's stroke at the clip point
- **Broken-bar chevron gap** — split a horizontal bar into two pieces with a `〉`-shaped slice
of background (the labelled piece tapers to a `〉` point, then a gap, then the bar resumes).
Signals "cut / compressed off-scale". Best for horizontal-bar / diverging-bar outliers;
always label the true value on the labelled piece. **Keep the broken bar in its normal
non-hero color (`[context]`) — the break exists to stop an off-scale NON-hero from setting
the scale and stealing focus from the hero. Do NOT recolor it `[warm]`: `[warm]` is
sign/Δ-semantic (envelope P4), not a bar identity, and a red outlier competes with the hero.**
If the takeaway depends on the absolute magnitude of the outlier (not its
relative position), prefer adjusting the data presentation (split chart, two
panels) over a break.
**Multiple off-scale bars must NOT come out equal-length.** A break *compresses*
the scale; it does not *flatten* values. When several bars are broken, plot their
past-break portions on one shared compressed scale so the largest-magnitude bar is
still the longest and the order / rough proportions read correctly. Clipping
19k / 55k / 124k all to the same length erases the comparison — instead map them
to e.g. ~70 / ~115 / ~160px (monotonic in value), each with its own value label.
## Geometry
```xml
<!-- Y-axis break at axis x=80, around y=124. Two parallel diagonals cross the axis;
"≈" sits just to the left as a written cue. -->
<line x1="74" y1="130" x2="86" y2="118" stroke="[axis]" stroke-width="1.5"/>
<line x1="74" y1="136" x2="86" y2="124" stroke="[axis]" stroke-width="1.5"/>
<text x="68" y="129" font-size="[fs-meta]" fill="[axis]" text-anchor="end"></text>
<!-- In-bar break: a zigzag in the bar's outline color near where the bar is clipped. -->
<path d="M 200 130 L 208 124 L 216 136 L 224 124 L 232 130"
stroke="[ink]" stroke-width="1.5" fill="none"/>
<!-- Broken-bar zigzag slit (bar y=440..472): two polygons in the bar's NON-HERO color
([context], NOT [warm]) with a THIN, SHALLOW 〉-shaped slit of background between them
(~5px wide). Left piece's right edge is a shallow 〉 (point x=309, juts only ~9px); the
resume piece has a parallel 〉 edge. Keep the slit thin and the chevron shallow — a fine
zigzag cut, NOT a fat solid arrow. -->
<polygon points="200,440 300,440 309,456 300,472 200,472" fill="[context]"/>
<polygon points="305,440 314,456 305,472 420,472 420,440" fill="[context]"/>
```

View File

@@ -1,38 +0,0 @@
# bottom-multi-column-legend
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: pie, doughnut
## Content
---
title: Bottom multi-column legend
categories: [structural-legend]
chart_types: [pie, doughnut]
---
## When to use
A horizontal multi-column legend below the pie / doughnut for **4-5 slices**.
A single-row inline legend gets too wide past ~4 entries; a 2-row × 2-col
grid (or 1 × 4) stays compact in the empty strip between the slice geometry
(slice bottom ≈ y=480, given the canonical `cx=480 cy=300 r=180`) and the
reserved footnote line at footnote_y (tier L: 592).
## Geometry
```xml
<!-- 4-category legend, single row × 4 cols. Column anchors at x=56, 256, 456, 656.
Row anchor y=540. Each entry: 10×10 swatch + [fs-meta] label.
Hero entry bold + [ink]; others regular + [axis]. -->
<rect x="56" y="540" width="10" height="10" fill="[accent]"/>
<text x="72" y="549" font-size="[fs-meta]" font-weight="bold" fill="[ink]">Laptops</text>
<rect x="256" y="540" width="10" height="10" fill="[accent-light]"/>
<text x="272" y="549" font-size="[fs-meta]" fill="[axis]">Smartphones</text>
<rect x="456" y="540" width="10" height="10" fill="[accent-pale]"/>
<text x="472" y="549" font-size="[fs-meta]" fill="[axis]">Printers</text>
<rect x="656" y="540" width="10" height="10" fill="[context]"/>
<text x="672" y="549" font-size="[fs-meta]" fill="[axis]">Other</text>
```
If you need a 5th entry, drop to a 2-row × 3-col grid with row y=540 and y=568,
each row anchored at x=56 / 312 / 568 (3 cols wide). Anything past 5 entries
means the pie has too many slices — fold smaller ones into "Other" first.

View File

@@ -1,41 +0,0 @@
# cagr-arrow
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, line, indexed-line, stacked-area, small-multiples, combination, clustered-column
## Content
---
title: CAGR arrow
categories: [structural-scale]
chart_types: [column, line, indexed-line, stacked-area, small-multiples, combination, clustered-column]
---
## When to use
A flat curved arrow spanning multiple periods (start → end) with a centered CAGR
label. Use when the takeaway is the **growth rate over a period** rather than
any single point — typical for multi-year revenue / volume charts.
Pin the Q-control's y to the curve's END y so the end tangent is purely
horizontal — the arrowhead then aligns cleanly with the curve. The chord still
gives a clear sense of direction; the bulge keeps the arc readable as an
annotation, not a series line.
## Geometry
```xml
<!-- Quadratic curve from (80, 280) → (848, 210). Control = (480, 210); since
control_y == end_y, the end tangent direction is (end_x control_x, 0) =
(368, 0), pure horizontal-right — the arrowhead polygon below is also pure
horizontal so the two meet flush. The trade-off: the bulge is bigger than
a near-chord control would give — curve midpoint at t=0.5 is y=227.5, which
is 17.5px above the chord midpoint (245). Still reads as an annotation
because of the thin 1.5px [accent] stroke.
Replace "+6.4%" with your CAGR; for a deeper trend, lower control_y while
keeping control_y == end_y to preserve the flush arrowhead. -->
<path d="M 80 280 Q 480 210 848 210"
fill="none" stroke="[accent]" stroke-width="1.5"/>
<polygon points="842,206 850,210 842,214" fill="[accent]"/>
<text x="480" y="215" font-size="[fs-label]" font-weight="bold" fill="[accent]" text-anchor="middle">
CAGR +6.4%
</text>
```

View File

@@ -1,39 +0,0 @@
# category-span-bracket
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, diverging-bar, small-multiples, combination
## Content
---
title: Category-span bracket
categories: [hero-numeric-claim]
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, diverging-bar, small-multiples, combination]
---
## When to use
A square bracket on the plot's left or right edge spanning **multiple categories**,
with a label outside. Use for category-spanning numeric claims ("+90M jobs across
5 sectors", "Top-3 hold 62%"). If the claim sits on a single column instead:
- On stacked variants (`stacked-column` / `hundred-percent-stacked-column` /
`stacked-bar` / `clustered-column` / `diverging-bar` / `marimekko` /
`waterfall`) where the claim is a stack total or segment span (e.g., "54%"),
use **Vertical bracket measurement** — short bracket on one column's span.
- On plain `column` / `bar` / `line`, a single-bar height claim is best
conveyed by a bold `[accent]` data label above the bar, not a bracket.
## Geometry
```xml
<!-- Left bracket [ spanning y=[220,280], opening right toward the spanned bars. INSET it enough
that the outside (left) label stays inside the viewBox — anchoring a long label at the very
edge (x≈36) runs the text off-canvas. Here the spine is at x=150 so the label fits in x≥28.
Mirror x for a right bracket. Replace the tspans; 2 lines fit, 3rd via dy="1.2em". -->
<path d="M 158 280 L 150 280 L 150 220 L 158 220"
fill="none" stroke="[ink]" stroke-width="1"/>
<line x1="150" y1="250" x2="142" y2="250" stroke="[ink]" stroke-width="1"/>
<text x="138" y="247" font-size="[fs-label]" font-weight="bold" fill="[ink]" text-anchor="end">
<tspan x="138" dy="0">+90M jobs</tspan>
<tspan x="138" dy="1.2em">needed by 2030</tspan>
</text>
```

View File

@@ -1,28 +0,0 @@
# column-divider
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: clustered-column, marimekko
## Content
---
title: Column divider
categories: [structural-connector]
chart_types: [clustered-column, marimekko]
---
## When to use
A 1px ink vertical line between grouped column clusters (or between marimekko
sub-blocks). Place at the midpoint of the gap between adjacent groups. Use when
generous group gaps alone aren't enough to read the grouping structure.
For marimekko, draw dividers in **white** between sub-blocks within the same
column (it's a separator inside a filled rect, not on top of the host background).
## Geometry
```xml
<!-- Divider at gap-midpoint x=324. Top = plot top (no title band); bottom = the host's
baseline (clustered-column: stop at y=480 to clear the Δ-badge band; marimekko: y=548). -->
<line x1="324" y1="48" x2="324" y2="480"
stroke="[ink]" stroke-width="1"/>
```

View File

@@ -1,43 +0,0 @@
# delta-badge
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
## Content
---
title: Delta badge
categories: [hero-numeric-claim]
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination]
---
## When to use
Rounded rectangle with `+N%` / `N%` text on a colored fill. Use to spotlight a
*single-point* change (one bar vs prior period, one slice vs benchmark). Compact
and visually quiet — for heavier emphasis or when the badge needs a leader stem
pointing at its referent, use **Ellipse callout** instead.
**Badges form ONE family** (envelope §A System consistency): every badge in the chart
shares one construction — same shape, same size, same fill logic. Pick the family style
once: filled pills (`[context]` fill, bold `#ffffff` text) read as one quiet system;
the hero badge alone may step up to `[accent]` — or `[warm]`/`[positive]` under the
envelope §C semantic gate (the takeaway itself claims the negative outcome / turnaround;
a signed value alone is not a reason). Never mix filled and outlined pills, or two
different pill sizes, in one chart.
The `rx=4` here is the ONE blessed rounded corner — it is chrome on an annotation pill,
not a data mark. Never carry `rx` onto bars, columns, or slices (envelope "Anti-patterns").
## Geometry
```xml
<!-- A badge family: uniform [context] pills, hero steps up to [warm] (here the takeaway
itself is about the decline). All pills identical in shape and size. -->
<rect x="400" y="200" width="44" height="20" rx="4" ry="4" fill="[warm]"/>
<text x="422" y="214" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">75%</text>
<rect x="540" y="240" width="44" height="20" rx="4" ry="4" fill="[context]"/>
<text x="562" y="254" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">60%</text>
<rect x="680" y="260" width="44" height="20" rx="4" ry="4" fill="[context]"/>
<text x="702" y="274" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">55%</text>
```

View File

@@ -1,48 +0,0 @@
# ellipse-callout
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination
## Content
---
title: Ellipse callout
categories: [hero-numeric-claim, hero-call-out]
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, clustered-column, line, indexed-line, stacked-area, scatter, bubble, waterfall, marimekko, pie, doughnut, diverging-bar, small-multiples, combination]
---
## When to use
An ellipse (or circle) bubble around a Δ value, **optionally connected by a stem
+ arrowhead** to the data point it's calling out. Heavier visual weight than a
**Delta badge** — use it for the 1-2 most important points on a chart, not every
datum.
Stem variant: when the bubble is above/below the point, the stem ties the two
together visually. Drop the stem when the callout sits *beside* the element.
Circle variant (rx=ry): use for 2-line measurement annotations (e.g.,
"Gap vs target / 159").
Color: `[accent]` by default (the callout extends the hero's emphasis; bold white text);
the circle measurement variant is `[ink]` outline + `[ink]` text. `[positive]`/`[warm]`
only under the envelope §C gate — a "90% error rate" is GOOD news and stays on `[accent]`.
## Geometry
```xml
<!-- Ellipse callout above a line point at (380, 230). Bubble centered at (380, 180);
stem from bubble bottom (y=194) to point (y=222); arrowhead at the point.
Replace "+33%" with your value. -->
<ellipse cx="380" cy="180" rx="34" ry="14" fill="[accent]"/>
<text x="380" y="184" font-size="[fs-label]" font-weight="bold" fill="#ffffff" text-anchor="middle">+33%</text>
<line x1="380" y1="194" x2="380" y2="222" stroke="[accent]" stroke-width="1.5"/>
<polygon points="376,222 380,230 384,222" fill="[accent]"/>
<!-- Circle variant for a 2-line measurement annotation, beside (not above) the element.
No stem; drop the bubble's saturated fill and let the ink outline carry the weight. -->
<circle cx="600" cy="300" r="32" fill="none" stroke="[ink]" stroke-width="1.5"/>
<text x="600" y="294" font-size="[fs-meta]" fill="[ink]" text-anchor="middle">
<tspan x="600" dy="0">Gap vs</tspan>
<tspan x="600" dy="1.2em">target</tspan>
<tspan x="600" dy="1.2em" font-size="[fs-label]" font-weight="bold">159</tspan>
</text>
```

View File

@@ -1,30 +0,0 @@
# endpoint-difference-bracket
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, waterfall, diverging-bar, combination, clustered-column
## Content
---
title: Endpoint difference bracket
categories: [hero-numeric-claim]
chart_types: [column, stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, waterfall, diverging-bar, combination, clustered-column]
---
## When to use
A bracket on the **outside of exactly two columns** (or two endpoints) with a
single-end arrow and a Δ label. Use when the takeaway is a pairwise comparison
("+18% Q3 vs Q2", "EU vs APAC: +24pts"). For category-spanning claims (3+ bars),
use **Category-span bracket** instead.
## Geometry
```xml
<!-- Bracket on the right of two columns: spine x=672, top y=200, bottom y=320.
Arrow points right; label sits beside the arrowhead.
Replace "+18%" with your delta value. -->
<path d="M 660 200 L 672 200 L 672 320 L 660 320"
fill="none" stroke="[ink]" stroke-width="1"/>
<line x1="672" y1="260" x2="690" y2="260" stroke="[ink]" stroke-width="1"/>
<polygon points="690,256 698,260 690,264" fill="[ink]"/>
<text x="702" y="264" font-size="[fs-label]" font-weight="bold" fill="[ink]">+18%</text>
```

View File

@@ -1,38 +0,0 @@
# error-bar
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, bar, line, scatter, bubble, combination
## Content
---
title: Error bar
categories: [hero-reference-marker]
chart_types: [column, bar, line, scatter, bubble, combination]
---
## When to use
A thin vertical whisker centered on a data point, with horizontal caps at top
and bottom, marking a value range — typically a **confidence interval**, error
bar, or min/max range around the point estimate. Use when each data point is a
summary of underlying data and the spread is worth communicating (sample
variance, valuation low/high, NPS confidence band).
For a wide horizontal envelope at a single time-period (rectangle backdrop
behind a polyline showing 3-year range), use **Range band** instead — that's
a 2D backdrop, this is a 1D whisker per point.
For horizontal charts (`bar`), transpose the primitive 90° — the whisker spans
horizontally and the caps are vertical lines.
## Geometry
```xml
<!-- Vertical error bar on a data point at (240, 280) with err = ±25 plot units.
Top end y = 280 - 25 = 255; bottom end y = 280 + 25 = 305.
Caps 8px wide, centered horizontally on the data point's x = 240.
Replace center / err per your data; clone for each point that needs a whisker.
For bar (horizontal), rotate 90° — main line spans horizontally, caps vertical. -->
<line x1="240" y1="255" x2="240" y2="305" stroke="[ink]" stroke-width="1"/>
<line x1="236" y1="255" x2="244" y2="255" stroke="[ink]" stroke-width="1"/>
<line x1="236" y1="305" x2="244" y2="305" stroke="[ink]" stroke-width="1"/>
```

View File

@@ -1,36 +0,0 @@
# mini-legend-pair
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: line, indexed-line, stacked-area, scatter, bubble, combination
## Content
---
title: Mini-legend pair
categories: [structural-legend]
chart_types: [line, indexed-line, stacked-area, scatter, bubble, combination]
---
## When to use
An inline legend combining **multiple marker types** (line + dashed line + circle
dot + filled square) for charts that mix more than one visual primitive — e.g.
solid line for actuals + dashed line for forecast / 3-year-avg. One row, 8-12px
between items, no border, no swatch box.
For 4+ categorical entries that share one primitive (all line, all bar), prefer
inline endpoint labels (envelope "Label Placement Vocabulary" "Endpoint" / "Stacked-edges"
category edges") instead — this snippet is meant for primitive-mixing legends,
not categorical fanout.
## Geometry
```xml
<!-- 2-entry mini-legend in the reclaimed top of the plot at y=40. Replace labels per your series. -->
<!-- Entry 1: dashed line + label -->
<line x1="56" y1="40" x2="80" y2="40" stroke="[context]" stroke-width="1.5" stroke-dasharray="3,3"/>
<text x="84" y="44" font-size="[fs-meta]" fill="[ink]">3-year avg</text>
<!-- Entry 2: solid line + circle dot + label -->
<line x1="180" y1="40" x2="204" y2="40" stroke="[accent]" stroke-width="1.5"/>
<circle cx="192" cy="40" r="3" fill="[accent]"/>
<text x="208" y="44" font-size="[fs-meta]" fill="[ink]">Fact</text>
```

View File

@@ -1,51 +0,0 @@
# quadrant-guides
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: scatter, bubble
## Content
---
title: Quadrant guides
categories: [hero-reference-marker]
chart_types: [scatter, bubble]
---
## When to use
Two crossing dashed reference lines partitioning a scatter plot into 4 quadrants,
each labeled with the framework's name. Use when the chart is a **2×2 matrix**
(BCG growth-share, fit-vs-priority, impact-vs-effort, etc.) and the takeaway is
*which quadrant* a data point sits in — not its precise coordinates.
The guides sit visually behind the dots — render BEFORE any scatter markers
(document order). Low opacity (~0.35) keeps them as a backdrop.
Labels go at the outer corner of each quadrant (far from the crossover),
text-anchored to the matching edge so they grow inward.
For a single threshold on one axis (e.g., "above target / below target"), use
**Value line** instead — quadrant guides imply *both* axes carry a meaningful
midpoint.
## Geometry
```xml
<!-- Quadrant guides for a 2×2 matrix on a scatter. Plot area x=48..904, y=44..544
(matches scatter.md / bubble.md scaffold — DO NOT mismatch or guides will fall
outside the plot). Crossover at the geometric midpoint (476, 294). Replace
if your threshold isn't at the midpoint (e.g., industry-average lines).
- Vertical guide spans full plot height; horizontal guide spans full plot width.
- Labels at outer corners, inset ~10px from plot edges; text-anchor "end" for
right quadrants, "start" for left, so they grow inward.
- [axis] color marks the labels as meta-frame so data dots dominate visually.
Replace label text per your framework. -->
<!-- Crossing dashed lines. Draw BEFORE scatter dots so dots overlap them. -->
<line x1="476" y1="44" x2="476" y2="544" stroke="[ink]" stroke-width="1" stroke-dasharray="4,3" stroke-opacity="0.35"/>
<line x1="48" y1="294" x2="904" y2="294" stroke="[ink]" stroke-width="1" stroke-dasharray="4,3" stroke-opacity="0.35"/>
<!-- Quadrant labels at outer corners (insets from plot edges). -->
<text x="894" y="64" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="end">Stars</text>
<text x="58" y="64" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="start">Question marks</text>
<text x="894" y="532" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="end">Cash cows</text>
<text x="58" y="532" font-size="[fs-meta]" font-weight="bold" fill="[axis]" text-anchor="start">Dogs</text>
```

View File

@@ -1,28 +0,0 @@
# range-band
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: line, indexed-line, stacked-area, combination
## Content
---
title: Range band
categories: [hero-reference-marker]
chart_types: [line, indexed-line, stacked-area, combination]
---
## When to use
A semi-transparent vertical rectangle showing a minmax range at a single
time-period (e.g., 3-year price range, daily highlow). Reader sees the
foreground series move *inside* the historical envelope. Render **before**
foreground polylines/markers so it sits visually behind.
Fill with `[accent]` at low opacity (`fill-opacity="0.15"`) — a faint accent envelope, no extra token to bind.
## Geometry
```xml
<!-- Range band at x_center for one time-period. Range [min=130, max=320].
Scale 1 px/unit, zero at y=460 → y_top = 460320 = 140, y_bottom = 460130 = 330.
Replace x_center / range values per your data. -->
<rect x="206" y="140" width="20" height="190" fill="[accent]" fill-opacity="0.15" stroke="none"/>
```

View File

@@ -1,41 +0,0 @@
# secondary-metric-overlay
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, diverging-bar, clustered-column
## Content
---
title: Secondary-metric overlay
categories: [hero-reference-marker]
chart_types: [column, stacked-column, hundred-percent-stacked-column, diverging-bar, clustered-column]
---
## When to use
A dashed line + square markers at each category position, overlaid on the
primary columns to express a **secondary metric** (e.g., "% pts vs previous
survey"). Use when one chart needs to carry two metrics: the columns carry the
primary metric, the overlay is a per-category index/delta.
Markers are filled `#ffffff` with a 1px `[ink]` outline so they read on top of
the columns behind.
For a single average / target instead of a per-category secondary metric, use
**Value line**.
## Geometry
```xml
<!-- Overlay across 4 category centers x = 160, 280, 400, 520.
Secondary axis scale puts the four values at y = 236, 254, 310, 346.
Replace the per-marker positions and labels per your data. -->
<polyline fill="none" stroke="[ink]" stroke-width="1" stroke-dasharray="3,3"
points="160,236 280,254 400,310 520,346"/>
<rect x="153" y="229" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
<rect x="273" y="247" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
<rect x="393" y="303" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
<rect x="513" y="339" width="14" height="14" fill="#ffffff" stroke="[ink]" stroke-width="1"/>
<text x="160" y="222" font-size="[fs-label]" fill="[ink]" text-anchor="middle">+9pts</text>
<text x="280" y="240" font-size="[fs-label]" fill="[ink]" text-anchor="middle">+5pts</text>
<text x="400" y="296" font-size="[fs-label]" fill="[ink]" text-anchor="middle">1pts</text>
<text x="520" y="332" font-size="[fs-label]" fill="[ink]" text-anchor="middle">+2pts</text>
```

View File

@@ -1,31 +0,0 @@
# series-connector
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: stacked-column, hundred-percent-stacked-column, stacked-bar, marimekko, combination
## Content
---
title: Series connector
categories: [structural-connector]
chart_types: [stacked-column, hundred-percent-stacked-column, stacked-bar, marimekko, combination]
---
## When to use
Thin gray line between the **tops of the same-series segment** in adjacent stacked
columns. Use when the chart has 3+ adjacent stacks and the reader needs to track
one series' segment-height change across them (e.g., "the green segment shrinks
from Q1 to Q3").
Skip on charts with only 2 stacks (the eye can pair tops without help). On
`stacked-column` / `hundred-percent-stacked-column` / `stacked-bar`, if the
hero is a per-stack TOTAL (not a per-segment trend across stacks), use
**Total difference arrow** instead.
## Geometry
```xml
<!-- Connector from segment top at (240, 300) to next stack's segment top at (320, 280).
Draw before the bar rects so they sit visually on top. -->
<line x1="240" y1="300" x2="320" y2="280"
stroke="[context]" stroke-width="1"/>
```

View File

@@ -1,43 +0,0 @@
# slice-breakout
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: pie, doughnut
## Content
---
title: Slice breakout
categories: [hero-call-out]
chart_types: [pie, doughnut]
---
## When to use
Pull one pie / doughnut slice radially outward from the center to spotlight it.
Use exactly once per chart, on the hero slice the takeaway names. For
non-protagonist accent on a slice, use **Ellipse callout** instead.
## Geometry
Offset the slice 12-20px along its centroid angle γ. Compute the translation:
- `Δx = offset × cos((γ 90) × π/180)`
- `Δy = offset × sin((γ 90) × π/180)`
Wrap the slice path in `<g transform="translate(Δx, Δy)">`.
```xml
<!-- Pie slice (centroid γ=198°), broken out 16 px. Canonical pie cx=480 cy=282 r=236.
Δx = 16 × cos(108°) = -4.94 → -5
Δy = 16 × sin(108°) = 15.22 → 15
Replace the path's M/L/A coordinates with your slice's geometry. -->
<g transform="translate(-5 15)">
<path d="M 480,282 L 619,473 A 236,236 0 0,1 256,355 Z"
fill="[accent]" stroke="[bg]" stroke-width="2"/>
</g>
<!-- Doughnut slice variant (annulus segment, r=236 r_inner=130): wrap the full
annulus path the same way. The translated path inherits the original arc
geometry; only the screen position shifts. -->
<g transform="translate(-5 15)">
<path d="M 619,473 A 236,236 0 0,1 256,355 L 356,322 A 130,130 0 0,0 556,387 Z"
fill="[accent]" stroke="[bg]" stroke-width="2"/>
</g>
```

View File

@@ -1,50 +0,0 @@
# total-difference-arrow
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: stacked-column, hundred-percent-stacked-column, stacked-area, stacked-bar
## Content
---
title: Total difference arrow
categories: [hero-numeric-claim]
chart_types: [stacked-column, hundred-percent-stacked-column, stacked-area, stacked-bar]
---
## When to use
A curved arrow spanning **between two stacked-column tops**, with a Δ% label
above the arc. Use when the takeaway is the change in the *aggregate total*
(e.g., "Revenue Q1 → Q3 +25%", "Headcount Before → After 18%"). The reader's
eye sweeps from earlier column to later, lands on the percentage.
Distinct from **Endpoint difference bracket** — that one is a side bracket
measuring two columns' span; this one floats *above* and connects their tops,
framing the total walk. Use **Endpoint difference bracket** when the takeaway
is "EU vs APAC" (pairwise endpoint comparison); use this snippet when the
takeaway is "Q1 → Q3" (total trajectory).
On `hundred-percent-stacked-column` the Δ measures the absolute totals shown
by the **100% indicator** band (not the 100% labels themselves — those are
trivially equal across columns).
For horizontal stacks (`stacked-bar`), transpose the primitive 90°: the arrow
spans rightward between two row ends instead of upward between two column tops.
## Geometry
```xml
<!-- Total Δ arrow connecting two stacked-column tops. Example uses:
- Column 1: center x=240, top y=228 (total height 320 above baseline y=548)
- Column 2: center x=520, top y=148 (total height 400; col 2 is taller → ascending Δ)
- Arrow anchors at +20px above each top: start (240, 208), end (520, 128)
- Control point (380, 128) = midpoint x at end y → flat end tangent for clean arrowhead
- Curve is monotonically ascending (in screen sense): y goes 208 → 128 with no wiggle
- Label at x=380 (curve midpoint), y=118 (30px above curve midpoint y=148)
Replace endpoints, control x, and label per your column tops + delta value.
For a descending Δ (col 2 < col 1), mirror y's (start lower, end higher) — keep [accent]
(or [ink]); switch to [warm] ONLY when the decline itself is the takeaway's negative claim
(loss / shrinkage emphasized), per envelope §C. -->
<path d="M 240 208 Q 380 128 520 128"
fill="none" stroke="[accent]" stroke-width="1.5"/>
<polygon points="528,128 520,124 520,132" fill="[accent]"/>
<text x="380" y="118" font-size="[fs-label]" font-weight="bold" fill="[accent]" text-anchor="middle">+25%</text>
```

View File

@@ -1,30 +0,0 @@
# triangle-pointer
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, bar, line, stacked-area, bubble, waterfall, combination
## Content
---
title: Triangle pointer
categories: [hero-call-out]
chart_types: [column, bar, line, stacked-area, bubble, waterfall, combination]
---
## When to use
A small filled triangle marking the **endpoint** of a value / target line, or
the tip of an annotation leader. Cheaper visual weight than an arrowhead-on-line;
use as a "pin" rather than as a directional flow.
Direction is set via polygon vertex order — left, right, up, down — pick the
side that **faces the chart it's pointing into** (e.g., a left-pointing pin sits
to the right of the chart's right edge).
## Geometry
```xml
<!-- Pointing LEFT at (900, 320). Right-end pin of a horizontal target line. -->
<polygon points="900,320 910,312 910,328" fill="[ink]"/>
<!-- Pointing UP at (480, 250). Pairs with an Ellipse callout above a line point. -->
<polygon points="480,250 472,258 488,258" fill="[accent]"/>
```

View File

@@ -1,35 +0,0 @@
# value-line
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, diverging-bar, small-multiples, combination, clustered-column
## Content
---
title: Value line
categories: [hero-reference-marker]
chart_types: [column, stacked-column, hundred-percent-stacked-column, bar, stacked-bar, line, indexed-line, stacked-area, scatter, bubble, waterfall, diverging-bar, small-multiples, combination, clustered-column]
---
## When to use
Horizontal reference line (average, target, prior period, breakeven) with a moveable
label at the right. Use when the takeaway compares the data **against a single
constant baseline** the reader doesn't see in the bars themselves ("X is above the
industry average", "3 of 5 missed target").
On column-class charts (`column` / `stacked-column` /
`hundred-percent-stacked-column` / `clustered-column` / `diverging-bar`), if
the secondary metric varies **per category** (different value at each x
position, not a flat line), use **Secondary-metric overlay** instead. For
non-column-class charts (`line` / `scatter` / etc.), a per-category secondary
metric is just a second series — draw another polyline / point set, no
separate snippet needed.
## Geometry
```xml
<!-- Reference at y=320 across plot width 28..900. Label right of plot end.
Replace "Avg 4.2" with your baseline name + value. -->
<line x1="28" y1="320" x2="900" y2="320"
stroke="[context]" stroke-width="1" stroke-dasharray="4,3"/>
<text x="904" y="324" font-size="[fs-meta]" fill="[context]">Avg 4.2</text>
```

View File

@@ -1,33 +0,0 @@
# vertical-bracket-measurement
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: stacked-column, hundred-percent-stacked-column, stacked-bar, waterfall, diverging-bar, clustered-column, marimekko
## Content
---
title: Vertical bracket measurement
categories: [hero-numeric-claim]
chart_types: [stacked-column, hundred-percent-stacked-column, stacked-bar, waterfall, diverging-bar, clustered-column, marimekko]
---
## When to use
A short vertical bracket with **arrowheads pointing inward** at top and bottom,
calling out "this height range = N" on a single column or stack section. Used
to mark a stack total or a positive-vs-negative aggregate ("54%" / "51%").
Distinct from **Category-span bracket** — that one is a tall rectangular bracket
spanning *multiple* categories; this one measures *one* column's vertical span.
## Geometry
```xml
<!-- Bracket on the left side of a stack from y=214 (top) to y=314 (zero line),
anchored at x=200 (just left of the column). Arrowheads point INWARD (each
triangle's BASE sits on the measured boundary, APEX points toward bracket
center — the standard ⊢⊣ dimension-line convention).
Replace "54%" with your aggregate value. -->
<line x1="200" y1="214" x2="200" y2="314" stroke="[accent]" stroke-width="1.5"/>
<polygon points="196,214 204,214 200,222" fill="[accent]"/>
<polygon points="196,314 204,314 200,306" fill="[accent]"/>
<text x="194" y="268" font-size="[fs-hero]" font-weight="bold" fill="[accent]" text-anchor="end">54%</text>
```

View File

@@ -1,27 +0,0 @@
# waterfall-connector
## Context
- Role: snippet
- Purpose: Chart design snippet for chart types: waterfall
## Content
---
title: Waterfall connector
categories: [structural-connector]
chart_types: [waterfall]
---
## When to use
Dotted horizontal line linking the top of bar N to the base of bar N+1. Used to
visually thread the "walk" through anchor + driver columns. The waterfall skeleton
prescribes when and where these go; this snippet is the geometry template.
Draw connectors **before** the bar rects (document order) so bars overlap them.
## Geometry
```xml
<!-- Connector y matches the top of bar N's stack on the side it leaves, which
equals the top of bar N+1 on the side it arrives. If the y's don't match,
the math behind your driver values is wrong — recheck before drawing. -->
<line x1="200" y1="224" x2="240" y2="224"
stroke="[context]" stroke-width="1" stroke-dasharray="2,2"/>

View File

@@ -1,18 +0,0 @@
# Generate SVG Chart Tool Description
## Context
- Role: reference
- Purpose: Describes generated chart outputs.
## Content
```text
Compute the exact bounding box of one or more SVG custom-shape paths. You CANNOT eyeball a path's real size, so before writing any <path slide:role="shape" slide:shape-type="custom"> call this with each path's `d`. For each path it returns the true width/height, a normalized `d` (shifted to the (0,0) origin) and an (offsetX, offsetY). Author the element as: <path slide:shape-type="custom" d="<returned d>" slide:width="<width>" slide:height="<height>" transform="translate(<offsetX>,<offsetY>)" .../> — never set slide:width/slide:height to the slide/canvas size.
```
## generate_svg_chart
生成一张 SVG chart 文件。下含①工具描述 ②入参 schema含 chart_type 路由规则,选型方法论唯一真相源,见 chart service implementation③chart 子 agent 契约模板(chart contract template) ④设计简报(\_envelope.md始终注入) ⑤signature snippets(signature snippets按 chart_type 动态选取)。
① 工具描述:
```text
Generate ONE SVG chart as a standalone file. Output viewBox is fixed at 960×600. Returns file_path (where the SVG was written), drive_token, and design_path (auditable design.md sidecar).
```

Some files were not shown because too many files have changed in this diff Show More