mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
41 Commits
feat/lark-
...
feat-svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b35d1e77 | ||
|
|
8d0197630f | ||
|
|
7ef44f7c27 | ||
|
|
33596111c7 | ||
|
|
fe8620425d | ||
|
|
76214a6176 | ||
|
|
a843ef0ac2 | ||
|
|
2a3e6ef2ef | ||
|
|
16f075b04a | ||
|
|
e5e17c17cf | ||
|
|
46014e9b77 | ||
|
|
57cc929ad1 | ||
|
|
bd63a20342 | ||
|
|
d82d4e3333 | ||
|
|
66ea925c3a | ||
|
|
0672f6de28 | ||
|
|
4dc182b8dd | ||
|
|
306307b3b3 | ||
|
|
589200c8c2 | ||
|
|
a215a33c8b | ||
|
|
1666c4db43 | ||
|
|
f3a40e4cda | ||
|
|
00222052ef | ||
|
|
f8950cdc8a | ||
|
|
74e7c5abee | ||
|
|
50754e53b1 | ||
|
|
ca8efe5d92 | ||
|
|
5ae2594a5f | ||
|
|
fd96f6e895 | ||
|
|
81c36bcf85 | ||
|
|
283462a36f | ||
|
|
d4e074a494 | ||
|
|
15e7ab8b66 | ||
|
|
f043ee61d8 | ||
|
|
5b264cf7b2 | ||
|
|
ead6362ab6 | ||
|
|
9c0c5ae26a | ||
|
|
8a450b6437 | ||
|
|
e196f68ef6 | ||
|
|
dff21a86ec | ||
|
|
38bf5402d9 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,28 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
@@ -1355,7 +1333,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
|
||||
@@ -22,6 +22,11 @@ import (
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdAuthWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdAuthWithContext creates the auth command with subcommands.
|
||||
func NewCmdAuthWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
@@ -38,7 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLoginWithContext(ctx, f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
|
||||
@@ -42,6 +42,11 @@ var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
return NewCmdAuthLoginWithContext(context.Background(), f, runF)
|
||||
}
|
||||
|
||||
// NewCmdAuthLoginWithContext creates the auth login subcommand.
|
||||
func NewCmdAuthLoginWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -73,7 +78,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
|
||||
18
cmd/build.go
18
cmd/build.go
@@ -90,8 +90,9 @@ func WithoutPlugins() BuildOption {
|
||||
}
|
||||
|
||||
// WithoutStrictMode builds the complete repository-owned command tree without
|
||||
// applying user/profile strict-mode pruning. It is intended for offline
|
||||
// inspection tools, not production execution.
|
||||
// applying user/profile strict-mode pruning or credential-backed bootstrap
|
||||
// probes. It is intended for offline inspection tools and pure local commands
|
||||
// that must not require account configuration.
|
||||
func WithoutStrictMode() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipStrictMode = true
|
||||
@@ -146,6 +147,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
o(cfg)
|
||||
}
|
||||
}
|
||||
if cfg.skipStrictMode {
|
||||
ctx = cmdutil.ContextWithCredentialBootstrapDisabled(ctx)
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
@@ -192,10 +196,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuthWithContext(ctx, f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoamiWithContext(ctx, f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -218,8 +222,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
if !cfg.skipStrictMode {
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
|
||||
34
cmd/root.go
34
cmd/root.go
@@ -103,10 +103,16 @@ func Execute() int {
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
ctx := context.Background()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
buildOpts := []BuildOption{
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
}
|
||||
if isLocalSVGlideInvocation(rawInvocationArgs) {
|
||||
buildOpts = append(buildOpts, WithoutStrictMode())
|
||||
}
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
buildOpts...,
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
@@ -130,6 +136,30 @@ func Execute() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isLocalSVGlideInvocation(args []string) bool {
|
||||
positionals := make([]string, 0, 2)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--profile":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
continue
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
continue
|
||||
default:
|
||||
positionals = append(positionals, arg)
|
||||
if len(positionals) == 2 {
|
||||
return positionals[0] == "slides" && positionals[1] == "+create-svglide"
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
|
||||
@@ -5,9 +5,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -26,6 +29,27 @@ import (
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
type countingKeychain struct {
|
||||
gets int
|
||||
sets int
|
||||
removes int
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Get(service, account string) (string, error) {
|
||||
k.gets++
|
||||
return "", fmt.Errorf("unexpected keychain Get for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Set(service, account, value string) error {
|
||||
k.sets++
|
||||
return fmt.Errorf("unexpected keychain Set for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Remove(service, account string) error {
|
||||
k.removes++
|
||||
return fmt.Errorf("unexpected keychain Remove for %s/%s", service, account)
|
||||
}
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
// auth, config, and schema commands have auth check disabled,
|
||||
// while api does not.
|
||||
@@ -75,6 +99,63 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalSVGlideInvocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "local svglide", args: []string{"slides", "+create-svglide", "--action", "init"}, want: true},
|
||||
{name: "with profile", args: []string{"--profile", "demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "with profile equals", args: []string{"--profile=demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "other slides shortcut", args: []string{"slides", "+create"}, want: false},
|
||||
{name: "root help", args: []string{"--help"}, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLocalSVGlideInvocation(tt.args); got != tt.want {
|
||||
t.Fatalf("isLocalSVGlideInvocation(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSVGlideRootCommandDoesNotTouchKeychain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
kc := &countingKeychain{}
|
||||
_, rootCmd, _ := buildInternal(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(&in, &out, &errOut),
|
||||
WithKeychain(kc),
|
||||
WithoutStrictMode(),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
rootCmd.SetArgs([]string{
|
||||
"slides",
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout=%s\nstderr=%s", err, out.String(), errOut.String())
|
||||
}
|
||||
if kc.gets != 0 || kc.sets != 0 || kc.removes != 0 {
|
||||
t.Fatalf("keychain touched: gets=%d sets=%d removes=%d", kc.gets, kc.sets, kc.removes)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
|
||||
@@ -54,6 +54,12 @@ type Options struct {
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdWhoamiWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdWhoamiWithContext creates the whoami command using the build context
|
||||
// for registration-time strict-mode presentation.
|
||||
func NewCmdWhoamiWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
@@ -63,7 +69,7 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &opts.As)
|
||||
// Output is always JSON. Accept (and ignore) --json so existing
|
||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||
// mode exists.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# `slides +create-svglide` Codex Runtime Design
|
||||
|
||||
Date: 2026-07-02
|
||||
Branch: `feat-svglide-07`
|
||||
Scope: first local-only version of `lark-cli slides +create-svglide`
|
||||
|
||||
## Result
|
||||
|
||||
Build `slides +create-svglide` as a staged local runtime for AnyGen SVG Slides. The command creates and manages a run directory that Codex can fill with generated content, assets, and SVG slides. The CLI owns state, prompts, schemas, validation, preview, receipts, and recovery. Codex owns LLM reasoning, web research, image/search execution, chart design, and SVG authoring.
|
||||
|
||||
The first version does not publish to Feishu Slides. It must produce a local, inspectable SVG deck workbench.
|
||||
|
||||
## Context
|
||||
|
||||
`feat-svglide-07` currently starts from the latest `origin/main` and has only the existing Slides XML shortcut surface. There is no current `+create-svglide` implementation on this branch.
|
||||
|
||||
The AnyGen SVG Slides prompt should be reused as contracts and workflow rules, not pasted as one large prompt. Its value is split across request interpretation, research, design brief, outline, `slide_content.md`, asset planning, SVG authoring, protocol validation, preview, and repair.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a staged `slides +create-svglide` command group.
|
||||
- Create a local run directory under a user-specified `--out` path, usually `.lark-slides/svglide-runs/<run-id>`.
|
||||
- Generate prompt task files that tell Codex exactly what to produce for each stage.
|
||||
- Generate JSON schemas for stage outputs.
|
||||
- Track stage state in `run.json`.
|
||||
- Validate JSON outputs, SVG protocol basics, asset href existence, slide count, placeholder slides, and preview generation.
|
||||
- Generate `preview.html` for local inspection.
|
||||
- Write receipts and `repair_queue.md` so failed runs can resume from the current stage.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No online Feishu Slides creation.
|
||||
- No `slide_engine` or `slide` server changes.
|
||||
- No SVG-to-SXSD conversion.
|
||||
- No built-in model API provider.
|
||||
- No built-in web search, image generation, or image search client.
|
||||
- No complete 12-agent process runner.
|
||||
- No PPTX import/edit workflow.
|
||||
|
||||
## Command Surface
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide init --title "Demo" --input ./source.md --audience "..." --delivery-mode self_read --pages 8 --out ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide next <run-dir>
|
||||
lark-cli slides +create-svglide status <run-dir>
|
||||
lark-cli slides +create-svglide validate <run-dir>
|
||||
lark-cli slides +create-svglide preview <run-dir>
|
||||
```
|
||||
|
||||
`init` creates the run directory, writes the initial request files, schemas, stage prompts, and `run.json`.
|
||||
|
||||
`next` reads `run.json`, finds the next stage, verifies required inputs, renders or refreshes that stage's Codex task prompt, and reports the exact files Codex must create. It must not pretend LLM work is complete.
|
||||
|
||||
`status` checks declared outputs and receipts for each stage, then prints the current stage, missing files, and next useful command.
|
||||
|
||||
`validate` runs deterministic checks and writes validation receipts.
|
||||
|
||||
`preview` writes `preview.html` from `outline/deck.json` and `slides/*.svg`.
|
||||
|
||||
## Run Directory Contract
|
||||
|
||||
```text
|
||||
<run-dir>/
|
||||
run.json
|
||||
README.md
|
||||
request/request.json
|
||||
request/source_manifest.json
|
||||
research/research_notes.md
|
||||
research/sources.json
|
||||
brief/design_brief.json
|
||||
brief/visual_system.json
|
||||
outline/deck.json
|
||||
content/slide_content.md
|
||||
content/slide_content.json
|
||||
assets/assets_plan.json
|
||||
assets/images/
|
||||
assets/charts/
|
||||
slides/*.svg
|
||||
prompts/*.task.md
|
||||
schemas/*.schema.json
|
||||
receipts/*.json
|
||||
receipts/generation_summary.md
|
||||
repair_queue.md
|
||||
preview.html
|
||||
```
|
||||
|
||||
The run directory is local agent state. It should not be committed by default.
|
||||
|
||||
## State Model
|
||||
|
||||
`run.json` stores:
|
||||
|
||||
- version
|
||||
- runtime, always `codex` in v1
|
||||
- command name
|
||||
- title
|
||||
- created and updated timestamps
|
||||
- current stage
|
||||
- stage list with status, inputs, outputs, and receipt path
|
||||
- important artifact paths
|
||||
- policy flags: `publish_enabled=false`, `network_by_codex=true`, `image_generation_by_codex=true`, `overwrite=false`
|
||||
|
||||
Stage statuses:
|
||||
|
||||
```text
|
||||
pending
|
||||
ready
|
||||
in_progress
|
||||
done
|
||||
failed
|
||||
blocked
|
||||
needs_repair
|
||||
```
|
||||
|
||||
## Stage Design
|
||||
|
||||
### 1. request
|
||||
|
||||
Role: Request Interpreter
|
||||
|
||||
Input: CLI flags and local source path.
|
||||
|
||||
Output: `request/request.json`, `request/source_manifest.json`.
|
||||
|
||||
Validation: title, audience, delivery mode, page count, and source references must be explicit or marked missing.
|
||||
|
||||
### 2. research
|
||||
|
||||
Role: Researcher
|
||||
|
||||
Input: request files and source files.
|
||||
|
||||
Output: `research/research_notes.md`, `research/sources.json`.
|
||||
|
||||
Validation: key facts need source references. Codex may perform web research, but the CLI only validates resulting files.
|
||||
|
||||
### 3. design_brief
|
||||
|
||||
Role: Design Brief Resolver and Visual System Planner
|
||||
|
||||
Input: request and research outputs.
|
||||
|
||||
Output: `brief/design_brief.json`, `brief/visual_system.json`.
|
||||
|
||||
Validation: narrative spine, depth, tone, and visual system dimensions must be present.
|
||||
|
||||
### 4. outline
|
||||
|
||||
Role: Outline Planner
|
||||
|
||||
Input: design brief.
|
||||
|
||||
Output: `outline/deck.json`.
|
||||
|
||||
Validation: page count matches request; each slide has id, title, summary, role, and key message.
|
||||
|
||||
### 5. slide_content
|
||||
|
||||
Role: Content Builder
|
||||
|
||||
Input: deck outline and research notes.
|
||||
|
||||
Output: `content/slide_content.md`, `content/slide_content.json`.
|
||||
|
||||
Validation: every slide has key material, content blocks, and source notes. This is content planning, not final layout.
|
||||
|
||||
### 6. assets
|
||||
|
||||
Role: Asset Planner and Chart Generator
|
||||
|
||||
Input: slide content and visual system.
|
||||
|
||||
Output: `assets/assets_plan.json`, optional `assets/images/*`, optional `assets/charts/*.svg`.
|
||||
|
||||
Validation: every planned asset has purpose plus either a local path or a fallback. Chart takeaway must be written before chart type.
|
||||
|
||||
### 7. svg_author
|
||||
|
||||
Role: SVG Author
|
||||
|
||||
Input: deck, slide content, visual system, and assets.
|
||||
|
||||
Output: `slides/*.svg`.
|
||||
|
||||
Validation: each slide must contain more than a background. Each slide needs a background, title, visible content or visual element, semantic id, and valid SVG root.
|
||||
|
||||
### 8. validate_preview_repair
|
||||
|
||||
Role: Protocol Validator, Preview Agent, and Repair Agent
|
||||
|
||||
Input: generated slides.
|
||||
|
||||
Output: `receipts/lint.json`, `receipts/preview.json`, `repair_queue.md`, `preview.html`.
|
||||
|
||||
Validation: SVG protocol lint, local href checks, slide count match, preview write success, and unresolved issues recorded in the repair queue.
|
||||
|
||||
## Code Layout
|
||||
|
||||
```text
|
||||
shortcuts/slides/
|
||||
slides_create_svglide.go
|
||||
slides_create_svglide_test.go
|
||||
|
||||
internal/svglide/
|
||||
run.go
|
||||
init.go
|
||||
stage.go
|
||||
prompt.go
|
||||
schema.go
|
||||
validate.go
|
||||
preview.go
|
||||
receipt.go
|
||||
```
|
||||
|
||||
The shortcut package should stay thin. State, prompt rendering, validation, and preview logic belong in `internal/svglide` so they can be tested without a Cobra/runtime-heavy command harness.
|
||||
|
||||
## Skill Documentation
|
||||
|
||||
Update `skills/lark-slides/SKILL.md` and add a focused reference file for the local SVG runtime. The skill should explain that `+create-svglide` is local-only in v1, requires Codex to fill stage outputs, and must not be described as an online publish path.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing required inputs block the stage and write a receipt.
|
||||
- Invalid JSON or schema mismatch marks the stage failed.
|
||||
- Invalid SVG marks `needs_repair` and writes `repair_queue.md`.
|
||||
- Existing output paths are not overwritten unless an explicit overwrite policy is enabled.
|
||||
- Partially completed stages remain inspectable; reruns resume from the current stage.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `init` creates the expected directory tree and `run.json`.
|
||||
- `init` refuses to overwrite an existing run directory by default.
|
||||
- `status` identifies missing outputs.
|
||||
- `next` renders the correct stage prompt and does not mark Codex-only stages done.
|
||||
- `validate` catches invalid SVG, missing hrefs, placeholder slides, and slide count mismatch.
|
||||
- `preview` writes HTML that references generated SVG files.
|
||||
|
||||
Fixtures:
|
||||
|
||||
- `testdata/svglide_run_valid/`
|
||||
- `testdata/svglide_run_invalid/`
|
||||
|
||||
No live end-to-end test is required for v1 because this version does not call Feishu APIs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A user can initialize a run directory from local input.
|
||||
- Codex can follow generated task prompts stage by stage.
|
||||
- The CLI can report status and missing artifacts.
|
||||
- The CLI can validate a completed local SVG deck.
|
||||
- The CLI can generate local preview HTML.
|
||||
- Failed validation produces actionable repair output.
|
||||
- No online presentation is created.
|
||||
|
||||
## Further Judgment
|
||||
|
||||
This design deliberately optimizes for artifact contracts rather than agent-count symmetry. Once the local runtime is stable, individual stages can be split into fuller agents without changing the run directory contract.
|
||||
2420
docs/vendor/anygen-svg/source.full.md
vendored
Normal file
2420
docs/vendor/anygen-svg/source.full.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8
docs/vendor/anygen-svg/source.meta.json
vendored
Normal file
8
docs/vendor/anygen-svg/source.meta.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"doc_url": "https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd",
|
||||
"local_full_snapshot": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.md",
|
||||
"local_handoff": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md",
|
||||
"fetched_by": "local export",
|
||||
"fetched_for": "slides +create-svglide AnyGen SVG prompt runtime experiment",
|
||||
"experiment_mode": "experiment_unrestricted_assets"
|
||||
}
|
||||
20
docs/vendor/anygen-svg/source.outline.md
vendored
Normal file
20
docs/vendor/anygen-svg/source.outline.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# AnyGen SVG Slides Local Outline
|
||||
|
||||
Source full snapshot: `docs/vendor/anygen-svg/source.full.md`
|
||||
Source handoff: `/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md`
|
||||
Remote doc: `https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd`
|
||||
|
||||
Required sections to split:
|
||||
|
||||
- System prompt(编排 / mode_system_prompt_svg)
|
||||
- SVG reference(协议 schema + 设计规范 / svg_reference)
|
||||
- resolve_design_brief
|
||||
- slide_outline
|
||||
- activate_slides_edit
|
||||
- slides_edit
|
||||
- finish_slides_edit
|
||||
- slide_organize
|
||||
- compute_custom_shape_bbox
|
||||
- generate_svg_chart
|
||||
- slides_convert
|
||||
- slides_parse_template
|
||||
@@ -48,6 +48,22 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
type skipCredentialBootstrapKey struct{}
|
||||
|
||||
// ContextWithCredentialBootstrapDisabled marks a command-tree build as
|
||||
// credential-free. Use it only for purely local command surfaces that must be
|
||||
// constructed without probing strict-mode, profile, or keychain state.
|
||||
func ContextWithCredentialBootstrapDisabled(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, skipCredentialBootstrapKey{}, true)
|
||||
}
|
||||
|
||||
// IsCredentialBootstrapDisabled reports whether credential-backed bootstrap
|
||||
// probes must be skipped for this context.
|
||||
func IsCredentialBootstrapDisabled(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(skipCredentialBootstrapKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
@@ -109,6 +125,9 @@ func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
}
|
||||
|
||||
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return nil
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -148,6 +167,9 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
// ResolveStrictMode returns the effective strict mode by reading
|
||||
// Account.SupportedIdentities from the credential provider chain.
|
||||
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
|
||||
34
internal/svglide/asset_path.go
Normal file
34
internal/svglide/asset_path.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validatePreparedImageAssetPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("image asset path must not be empty")
|
||||
}
|
||||
if strings.Contains(path, `\`) {
|
||||
return "", fmt.Errorf("image asset path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(path, "%") {
|
||||
return "", fmt.Errorf("image asset path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(path, ":") || strings.Contains(path, "//") || isAbsoluteRunPath(path) {
|
||||
return "", fmt.Errorf("image asset path %q must be a local assets/images/<file> path", raw)
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 3 || parts[0] != "assets" || parts[1] != "images" {
|
||||
return "", fmt.Errorf("image asset path %q must match assets/images/<file>", raw)
|
||||
}
|
||||
fileName := parts[2]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("image asset path %q must include a file name", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("image asset file name %q must not contain dot segments", fileName)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
46
internal/svglide/asset_path_test.go
Normal file
46
internal/svglide/asset_path_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePreparedImageAssetPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", path: "assets/images/hero.png", want: "assets/images/hero.png"},
|
||||
{name: "trim", path: " assets/images/hero.png ", want: "assets/images/hero.png"},
|
||||
{name: "empty", path: "", wantErr: true},
|
||||
{name: "remote", path: "https://example.com/hero.png", wantErr: true},
|
||||
{name: "parent directory", path: "../hero.png", wantErr: true},
|
||||
{name: "absolute", path: "/Users/example/hero.png", wantErr: true},
|
||||
{name: "file url", path: "file:///tmp/hero.png", wantErr: true},
|
||||
{name: "protocol relative", path: "//example.com/hero.png", wantErr: true},
|
||||
{name: "data url", path: "data:image/png;base64,AAAA", wantErr: true},
|
||||
{name: "percent", path: "assets/images/hero%2epng", wantErr: true},
|
||||
{name: "nested", path: "assets/images/nested/hero.png", wantErr: true},
|
||||
{name: "wrong directory", path: "assets/other/hero.png", wantErr: true},
|
||||
{name: "leading dot", path: "assets/images/.hero.png", wantErr: true},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png", wantErr: true},
|
||||
{name: "backslash", path: `assets\images\hero.png`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validatePreparedImageAssetPath(tt.path)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("path = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
468
internal/svglide/author.go
Normal file
468
internal/svglide/author.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSlideWidth = 960
|
||||
defaultSlideHeight = 540
|
||||
defaultAuthorBgColor = "#FFFFFF"
|
||||
defaultAuthorInkColor = "#111827"
|
||||
defaultAuthorMuteColor = "#6B7280"
|
||||
defaultAuthorAccent = "#2563EB"
|
||||
svgAuthorReceipt = "receipts/svg_author.json"
|
||||
)
|
||||
|
||||
type AuthorReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []string `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []authorDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type authorSlideContentFile struct {
|
||||
Slides []authorSlideContent `json:"slides"`
|
||||
}
|
||||
|
||||
type authorSlideContent struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Notes string `json:"notes"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
Visuals []authorSlideVisual `json:"visuals"`
|
||||
}
|
||||
|
||||
type authorSlideVisual struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
|
||||
type authorAssetsFile struct {
|
||||
Assets []authorAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type authorAsset struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Usage string `json:"usage"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type authorVisualSystem struct {
|
||||
ColorSystem struct {
|
||||
Background string `json:"background"`
|
||||
Ink string `json:"ink"`
|
||||
Muted string `json:"muted"`
|
||||
Accent string `json:"accent"`
|
||||
} `json:"color_system"`
|
||||
Typography struct {
|
||||
Title int `json:"title"`
|
||||
Body int `json:"body"`
|
||||
} `json:"typography"`
|
||||
LayoutLanguage string `json:"layout_language"`
|
||||
}
|
||||
|
||||
type authorTheme struct {
|
||||
Background string
|
||||
Ink string
|
||||
Muted string
|
||||
Accent string
|
||||
TitleSize int
|
||||
BodySize int
|
||||
}
|
||||
|
||||
type authorSlideTarget struct {
|
||||
Slide authorDeckSlide
|
||||
Content authorSlideContent
|
||||
Assets []authorAsset
|
||||
Path string
|
||||
Target string
|
||||
Page int
|
||||
}
|
||||
|
||||
func AuthorSlides(root string) (AuthorReport, error) {
|
||||
return authorSlides(root, nil)
|
||||
}
|
||||
|
||||
func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
deck, err := readAuthorDeck(safeRoot, strings.TrimSpace(run.Artifacts.Deck))
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
contentByID, err := readAuthorContent(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
theme, err := readAuthorTheme(safeRoot, "brief/visual_system.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
assetsBySlideID, err := readAuthorAssets(safeRoot, "assets/assets_plan.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := validateAuthorDeckContent(deck, contentByID); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
targets := make([]authorSlideTarget, 0, len(deck.Slides))
|
||||
report := AuthorReport{
|
||||
Status: StatusDone,
|
||||
Slides: make([]string, 0, len(deck.Slides)),
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if selectedPaths != nil && !selectedPaths[slidePath] {
|
||||
continue
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
targets = append(targets, authorSlideTarget{
|
||||
Slide: slide,
|
||||
Content: contentByID[strings.TrimSpace(slide.ID)],
|
||||
Assets: selectAuthorRenderableImageAssets(safeRoot, contentByID[strings.TrimSpace(slide.ID)], assetsBySlideID[strings.TrimSpace(slide.ID)]),
|
||||
Path: slidePath,
|
||||
Target: target,
|
||||
Page: i + 1,
|
||||
})
|
||||
report.Slides = append(report.Slides, slidePath)
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, svgAuthorReceipt)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, target.Assets, theme, target.Page, len(deck.Slides))
|
||||
if err := writeText(target.Target, svg); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
}
|
||||
if err := writeJSON(receiptTarget, StageReceipt{
|
||||
Stage: StageSVGAuthor,
|
||||
Status: StatusDone,
|
||||
Artifacts: report.Slides,
|
||||
}); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readAuthorDeck(safeRoot string, deckPath string) (authorDeck, error) {
|
||||
if deckPath == "" {
|
||||
return authorDeck{}, fmt.Errorf("deck artifact path is empty")
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
var deck authorDeck
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return authorDeck{}, fmt.Errorf("deck %q contains no slides", deckPath)
|
||||
}
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
func readAuthorContent(safeRoot string, path string) (map[string]authorSlideContent, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
var file authorSlideContentFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
byID := make(map[string]authorSlideContent, len(file.Slides))
|
||||
for _, slide := range file.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("slide content id must not be empty")
|
||||
}
|
||||
if _, exists := byID[id]; exists {
|
||||
return nil, fmt.Errorf("slide content id %q is duplicated", id)
|
||||
}
|
||||
byID[id] = slide
|
||||
}
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
func readAuthorAssets(safeRoot string, path string) (map[string][]authorAsset, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read assets plan %q: %w", path, err)
|
||||
}
|
||||
var file authorAssetsFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("read assets plan %q: %w", path, err)
|
||||
}
|
||||
bySlideID := make(map[string][]authorAsset, len(file.Assets))
|
||||
for _, asset := range file.Assets {
|
||||
if strings.TrimSpace(asset.Status) != "ready" {
|
||||
continue
|
||||
}
|
||||
slideID := strings.TrimSpace(asset.SlideID)
|
||||
bySlideID[slideID] = append(bySlideID[slideID], asset)
|
||||
}
|
||||
return bySlideID, nil
|
||||
}
|
||||
|
||||
func selectAuthorRenderableImageAssets(safeRoot string, content authorSlideContent, assets []authorAsset) []authorAsset {
|
||||
if len(content.Visuals) == 0 || len(assets) == 0 {
|
||||
return nil
|
||||
}
|
||||
assetByID := make(map[string]authorAsset, len(assets))
|
||||
for _, asset := range assets {
|
||||
if strings.TrimSpace(asset.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(asset.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
assetByID[id] = asset
|
||||
}
|
||||
for _, visual := range content.Visuals {
|
||||
if strings.TrimSpace(visual.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(visual.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
asset, ok := assetByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !authorImageAssetUsable(safeRoot, asset) {
|
||||
continue
|
||||
}
|
||||
return []authorAsset{asset}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorImageAssetUsable(_ string, asset authorAsset) bool {
|
||||
if strings.TrimSpace(asset.Type) != "image" {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSpace(asset.Path)
|
||||
return path != ""
|
||||
}
|
||||
|
||||
func validateAuthorDeckContent(deck authorDeck, contentByID map[string]authorSlideContent) error {
|
||||
deckIDs := make(map[string]bool, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("deck slide id must not be empty")
|
||||
}
|
||||
if deckIDs[id] {
|
||||
return fmt.Errorf("deck slide id %q is duplicated", id)
|
||||
}
|
||||
deckIDs[id] = true
|
||||
if _, ok := contentByID[id]; !ok {
|
||||
return fmt.Errorf("deck slide id %q is missing from slide content", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAuthorTheme(safeRoot string, path string) (authorTheme, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
var visual authorVisualSystem
|
||||
if err := json.Unmarshal(raw, &visual); err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
theme := authorTheme{
|
||||
Background: normalizeAuthorColor(visual.ColorSystem.Background, defaultAuthorBgColor),
|
||||
Ink: normalizeAuthorColor(visual.ColorSystem.Ink, defaultAuthorInkColor),
|
||||
Muted: normalizeAuthorColor(visual.ColorSystem.Muted, defaultAuthorMuteColor),
|
||||
Accent: normalizeAuthorColor(visual.ColorSystem.Accent, defaultAuthorAccent),
|
||||
TitleSize: visual.Typography.Title,
|
||||
BodySize: visual.Typography.Body,
|
||||
}
|
||||
if theme.TitleSize <= 0 {
|
||||
theme.TitleSize = 32
|
||||
}
|
||||
if theme.BodySize <= 0 {
|
||||
theme.BodySize = 16
|
||||
}
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
func normalizeAuthorColor(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if isAllowedAuthorHexColor(value) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isAllowedAuthorHexColor(value string) bool {
|
||||
if len(value) != 4 && len(value) != 7 && len(value) != 9 {
|
||||
return false
|
||||
}
|
||||
if value[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[1:] {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, assets []authorAsset, theme authorTheme, page int, total int) string {
|
||||
title := firstNonEmpty(slide.Title, "Untitled slide")
|
||||
keyMessage := firstNonEmpty(slide.KeyMessage, slide.Summary)
|
||||
bodyLines := authorBodyLines(content.Content)
|
||||
footer := strings.TrimSpace(deckTitle)
|
||||
if footer == "" {
|
||||
footer = "SVGlide"
|
||||
}
|
||||
footnote := authorSourceFootnote(content.SourceRefs)
|
||||
heroAsset := firstReadyAuthorImageAsset(assets)
|
||||
contentWidth := 848
|
||||
contentHeight := 404
|
||||
if heroAsset != nil {
|
||||
contentWidth = 500
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="%s" xmlns:slide="%s" width="%d" height="%d" viewBox="0 0 960 540" slide:role="slide">`+"\n", svgNamespace, slideNamespace, defaultSlideWidth, defaultSlideHeight)
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="540" fill="%s" data-role="background"/>`+"\n", escapeAttr(theme.Background))
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="8" fill="%s"/>`+"\n", escapeAttr(theme.Accent))
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="48" width="%d" height="%d" slide:role="shape" slide:shape-type="text">`+"\n", contentWidth, contentHeight)
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;">`+"\n", escapeAttr(theme.Ink))
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;font-weight:700;line-height:1.16;margin-bottom:16px;">%s</div>`+"\n", theme.TitleSize, escapeText(title))
|
||||
if keyMessage != "" {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.35;color:%s;margin-bottom:22px;">%s</div>`+"\n", maxInt(theme.BodySize+4, 18), escapeAttr(theme.Accent), escapeText(keyMessage))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <div style="border:1px solid #E5E7EB;border-radius:6px;padding:20px 24px;min-height:190px;background:#F9FAFB;">`+"\n")
|
||||
for _, line := range bodyLines {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.55;margin-bottom:8px;">- %s</div>`+"\n", theme.BodySize, escapeText(line))
|
||||
}
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
if footnote != "" {
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="456" width="520" height="18" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;font-size:12px;line-height:1.2;">%s</div>`+"\n", escapeAttr(theme.Muted), escapeText(footnote))
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
}
|
||||
if heroAsset != nil {
|
||||
fmt.Fprintf(&b, ` <image slide:role="image" slide:shape-type="image" href="%s" x="600" y="160" width="304" height="190"/>`+"\n", escapeAttr(heroAsset.Path))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="482" width="848" height="32" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;font-size:12px;display:flex;justify-content:space-between;">`+"\n", escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(&b, " <span>%s</span><span>%d / %d</span>\n", escapeText(footer), page, total)
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
fmt.Fprintf(&b, "</svg>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func authorBodyLines(content string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{"No content provided."}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func authorSourceFootnote(sourceRefs []string) string {
|
||||
if len(sourceRefs) == 0 {
|
||||
return ""
|
||||
}
|
||||
refs := make([]string, 0, len(sourceRefs))
|
||||
for _, ref := range sourceRefs {
|
||||
if trimmed := strings.TrimSpace(ref); trimmed != "" {
|
||||
refs = append(refs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "来源:" + strings.Join(refs, " / ")
|
||||
}
|
||||
|
||||
func firstReadyAuthorImageAsset(assets []authorAsset) *authorAsset {
|
||||
for i := range assets {
|
||||
asset := &assets[i]
|
||||
if strings.TrimSpace(asset.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(asset.Path) == "" {
|
||||
continue
|
||||
}
|
||||
return asset
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeText(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func escapeAttr(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
458
internal/svglide/author_test.go
Normal file
458
internal/svglide/author_test.go
Normal file
@@ -0,0 +1,458 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
report, err := AuthorSlides("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != StatusDone {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, StatusDone)
|
||||
}
|
||||
if len(report.Slides) != 2 {
|
||||
t.Fatalf("Slides len = %d, want 2: %+v", len(report.Slides), report.Slides)
|
||||
}
|
||||
receipt := readAuthorReceiptForTest(t)
|
||||
if receipt["stage"] != StageSVGAuthor {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageSVGAuthor)
|
||||
}
|
||||
if receipt["status"] != StatusDone {
|
||||
t.Fatalf("receipt status = %v, want %q", receipt["status"], StatusDone)
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
|
||||
for _, rel := range []string{"slides/01.svg", "slides/02.svg"} {
|
||||
raw, err := os.ReadFile(filepath.Join("demo", rel))
|
||||
if err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`slide:role="slide"`,
|
||||
`viewBox="0 0 960 540"`,
|
||||
`foreignObject`,
|
||||
`slide:role="shape"`,
|
||||
`slide:shape-type="text"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("%s missing %q:\n%s", rel, want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesFallsBackForUnsafeColorTokens(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"url(https://example.com/bg.svg)","ink":"red;background:url(https://example.com/x)","muted":"not-a-color","accent":"#abc"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, banned := range []string{"url(", "https://example.com", "red;background", "not-a-color"} {
|
||||
if strings.Contains(svg, banned) {
|
||||
t.Fatalf("SVG contains unsafe color token %q:\n%s", banned, svg)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`fill="#FFFFFF"`, `color:#111827`, `color:#6B7280`, `fill="#abc"`, `color:#abc`} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("SVG missing normalized/default color %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesPreflightsSlidePathsBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/../02.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected invalid second slide path to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "01.svg")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("first slide output exists after preflight failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsMissingContentBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected missing slide content to fail")
|
||||
}
|
||||
for _, rel := range []string{"slides/01.svg", "receipts/svg_author.json"} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s exists after content preflight failure, stat err = %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s1","content":"Duplicate body line","source_refs":[],"visuals":[{"id":"none-s1b","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected duplicate slide content id to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("svg_author receipt exists after duplicate content id failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForNoneVisualDespiteReadyAsset(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("visual type none should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForMismatchedVisualID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"other","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("mismatched visual id should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExperimentRemoteImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the remote hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`href="https://example.com/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("experiment remote image missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesSkipsUnsupportedReadyImageAssets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
asset string
|
||||
}{
|
||||
{
|
||||
name: "diagram",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero diagram","status":"ready"}]}`,
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"missing"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", tt.asset)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("unsupported asset should not render image:\n%s", string(raw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExistingAbsoluteImageAssetInExperiment(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), outside) || !strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("absolute asset should render image in experiment mode:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initAuthorDemoRun(t *testing.T, visualSystem string, deck string) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", visualSystem)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", deck)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersSourceFootnotes(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`来源`,
|
||||
`web1`,
|
||||
`slide:role="shape"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("source footnote missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersPreparedImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`slide:shape-type="image"`,
|
||||
`href="assets/images/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("prepared image asset missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersImageFootnoteAndMultilineBodyWithValidation(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthorInputsWithAnyGenContracts(t *testing.T, assets string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s2","content":"Point A\nPoint B\nPoint C","source_refs":["web1"],"visuals":[{"id":"none-s2","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", assets)
|
||||
}
|
||||
|
||||
func readAuthorReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "svg_author.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func mustWriteTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
194
internal/svglide/init.go
Normal file
194
internal/svglide/init.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type InitOptions struct {
|
||||
Title string
|
||||
Input string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Now time.Time
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func InitRun(root string, opts InitOptions) error {
|
||||
root = strings.TrimSpace(root)
|
||||
opts.Title = strings.TrimSpace(opts.Title)
|
||||
opts.Input = strings.TrimSpace(opts.Input)
|
||||
if root == "" {
|
||||
return fmt.Errorf("out path is required")
|
||||
}
|
||||
if opts.Title == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if opts.Input == "" {
|
||||
return fmt.Errorf("input is required")
|
||||
}
|
||||
safeRoot, err := validate.SafeOutputPath(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRunRoot(root, safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
safeInput, err := validate.SafeInputPath(opts.Input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInputOutsideRunRoot(safeRoot, safeInput); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Input = safeInput
|
||||
|
||||
if opts.Overwrite {
|
||||
return initOverwrite(safeRoot, opts)
|
||||
}
|
||||
|
||||
return initNoReplace(safeRoot, opts)
|
||||
}
|
||||
|
||||
func validateRunRoot(root string, safeRoot string) error {
|
||||
if filepath.Clean(root) == "." {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
canonicalCwd, err := vfs.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve working directory: %w", err)
|
||||
}
|
||||
if filepath.Clean(safeRoot) == filepath.Clean(canonicalCwd) {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputOutsideRunRoot(safeRoot string, safeInput string) error {
|
||||
root := filepath.Clean(safeRoot)
|
||||
input := filepath.Clean(safeInput)
|
||||
rel, err := filepath.Rel(root, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot compare input and output paths: %w", err)
|
||||
}
|
||||
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
|
||||
return fmt.Errorf("input path %q must be outside output run directory %q", safeInput, safeRoot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initNoReplace(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("%s already exists or cannot be created; refusing to overwrite: %w", safeRoot, err)
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func initOverwrite(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.RemoveAll(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func writeClaimedRunDirectory(safeRoot string, opts InitOptions) error {
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = vfs.RemoveAll(safeRoot)
|
||||
}
|
||||
}()
|
||||
if err := writeRunDirectory(safeRoot, safeRoot, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRunDirectory(writeRoot string, runRoot string, opts InitOptions) error {
|
||||
for _, dir := range []string{
|
||||
"request",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"assets/images",
|
||||
"slides",
|
||||
"schemas",
|
||||
"receipts",
|
||||
} {
|
||||
if err := vfs.MkdirAll(filepath.Join(writeRoot, dir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: opts.Title,
|
||||
Input: opts.Input,
|
||||
Audience: opts.Audience,
|
||||
DeliveryMode: opts.DeliveryMode,
|
||||
Pages: opts.Pages,
|
||||
Out: runRoot,
|
||||
Now: opts.Now,
|
||||
})
|
||||
run.Policy.Overwrite = opts.Overwrite
|
||||
if err := writeJSON(filepath.Join(writeRoot, "run.json"), run); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "request.json"), map[string]any{
|
||||
"title": opts.Title,
|
||||
"input": opts.Input,
|
||||
"audience": opts.Audience,
|
||||
"delivery_mode": opts.DeliveryMode,
|
||||
"pages": opts.Pages,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "source_manifest.json"), map[string]any{
|
||||
"sources": []map[string]string{{"path": opts.Input, "type": "local"}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeStaticFiles(writeRoot)
|
||||
}
|
||||
|
||||
func writeStaticFiles(root string) error {
|
||||
if err := writeText(filepath.Join(root, "README.md"), renderRunREADME()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePromptManifest(root); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, schema := range DefaultSchemas() {
|
||||
if err := writeText(filepath.Join(root, "schemas", name), schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderRunREADME() string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Local Run\n\n")
|
||||
b.WriteString("This directory is a local Codex-mediated SVG slides runtime. It does not publish to Feishu Slides.\n")
|
||||
return b.String()
|
||||
}
|
||||
380
internal/svglide/init_test.go
Normal file
380
internal/svglide/init_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitRunWritesDirectoryContract(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
canonicalCwd, err := filepath.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "demo"
|
||||
wantInput := filepath.Join(canonicalCwd, "source.md")
|
||||
err = InitRun(root, InitOptions{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Now: time.Date(2026, 7, 2, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"run.json",
|
||||
"README.md",
|
||||
"prompt_manifest.json",
|
||||
"request/request.json",
|
||||
"request/source_manifest.json",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"schemas/request.schema.json",
|
||||
"schemas/deck.schema.json",
|
||||
"receipts",
|
||||
"slides",
|
||||
"assets/images",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
|
||||
t.Fatalf("missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
if run.Input != wantInput {
|
||||
t.Fatalf("run.Input = %q, want %q", run.Input, wantInput)
|
||||
}
|
||||
|
||||
requestRaw, err := os.ReadFile(filepath.Join(root, "request", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(requestRaw, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request["title"] != "Demo" || request["input"] != wantInput || request["audience"] != "产品负责人" || request["delivery_mode"] != "self_read" || request["pages"] != float64(8) {
|
||||
t.Fatalf("unexpected request.json: %+v", request)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(root, "request", "source_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Sources []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest.Sources) != 1 || manifest.Sources[0].Path != wantInput || manifest.Sources[0].Type != "local" {
|
||||
t.Fatalf("unexpected source_manifest.json: %+v", manifest)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "prompts")); !os.IsNotExist(err) {
|
||||
t.Fatalf("prompts directory should not be generated per run, stat err = %v", err)
|
||||
}
|
||||
|
||||
promptRaw, err := os.ReadFile(filepath.Join(root, "prompt_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := string(promptRaw)
|
||||
for _, want := range []string{"mode_system_prompt_svg", "svg_reference", "tools/slides_edit.md", "tools/generate_svg_chart.md"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt manifest missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
schemaRaw, err := os.ReadFile(filepath.Join(root, "schemas", "deck.schema.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deckSchema map[string]any
|
||||
if err := json.Unmarshal(schemaRaw, &deckSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := deckSchema["properties"]; !ok || !strings.Contains(string(schemaRaw), "key_message") {
|
||||
t.Fatalf("deck schema missing properties/key_message: %s", string(schemaRaw))
|
||||
}
|
||||
if !strings.Contains(string(schemaRaw), `"minItems": 1`) || !strings.Contains(string(schemaRaw), `^slides/[^/]+\\.svg$`) {
|
||||
t.Fatalf("deck schema missing minItems/path pattern: %s", string(schemaRaw))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"source_manifest.schema.json",
|
||||
"sources.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"quality.schema.json",
|
||||
"receipt.schema.json",
|
||||
"lint.schema.json",
|
||||
"preview.schema.json",
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", name, err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("schema %s is not valid JSON: %v", name, err)
|
||||
}
|
||||
if schema["type"] == nil {
|
||||
t.Fatalf("schema %s missing type: %s", name, string(raw))
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{name: "request.schema.json", want: []string{`"purpose"`, `"language"`, `"visual_style_query"`}},
|
||||
{name: "design_brief.schema.json", want: []string{`"visual_system"`, `"narrative_spine"`, `"depth"`, `"tone"`}},
|
||||
{name: "deck.schema.json", want: []string{`"main_title"`, `"style_instruction"`, `"aesthetic_direction"`}},
|
||||
{name: "sources.schema.json", want: []string{`"retrieval"`}},
|
||||
{name: "slide_content.schema.json", want: []string{`"source_refs"`, `"visuals"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "assets_plan.schema.json", want: []string{`"experiment_unrestricted_assets"`, `"slide_id"`, `"status"`, `"deferred"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "quality.schema.json", want: []string{`"metrics"`}},
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", tc.name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", tc.name, err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("schema %s missing %s: %s", tc.name, want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "run.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRootWithoutRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantREADME := "keep this readme\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(wantREADME), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
gotREADME, readErr := os.ReadFile(filepath.Join(root, "README.md"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(gotREADME) != wantREADME {
|
||||
t.Fatalf("README overwritten: got %q, want %q", string(gotREADME), wantREADME)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected existing root refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunOverwriteReplacesOldRunDirectory(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(filepath.Join(root, "slides"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "slides", "old.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "slides", "old.svg")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old slide should be removed, stat err = %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !run.Policy.Overwrite {
|
||||
t.Fatalf("Policy.Overwrite = false, want true: %+v", run.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsOverlappingInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
input string
|
||||
overwrite bool
|
||||
}{
|
||||
{name: "same path overwrite", root: "source.md", input: "source.md", overwrite: true},
|
||||
{name: "input under output overwrite", root: "demo", input: "demo/source.md", overwrite: true},
|
||||
{name: "input under output no overwrite", root: "demo", input: "demo/source.md", overwrite: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.MkdirAll(filepath.Dir(tt.input), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(tt.input, []byte("source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(tt.root, InitOptions{Title: "Demo", Input: tt.input, Overwrite: tt.overwrite})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping input/output refusal")
|
||||
}
|
||||
got, readErr := os.ReadFile(tt.input)
|
||||
if readErr != nil {
|
||||
t.Fatalf("source should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "source" {
|
||||
t.Fatalf("source content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsUnsafePaths(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "absolute root", root: filepath.Join(cwd, "demo"), opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping root", root: "../escape", opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping input", root: "demo", opts: InitOptions{Title: "Demo", Input: "../source.md"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected unsafe path refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsRootResolvingToCWDWhenOverwrite(t *testing.T) {
|
||||
for _, root := range []string{".", "./", "subdir/.."} {
|
||||
t.Run(root, func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
markerPath := filepath.Join(cwd, "keep.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err == nil {
|
||||
t.Fatal("expected root resolving to CWD to be rejected")
|
||||
}
|
||||
got, readErr := os.ReadFile(markerPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("marker should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "keep" {
|
||||
t.Fatalf("marker content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPromptManifestContracts(t *testing.T) {
|
||||
manifest := DefaultPromptManifest()
|
||||
if manifest.Source != anyGenPromptRoot {
|
||||
t.Fatalf("Source = %q, want %q", manifest.Source, anyGenPromptRoot)
|
||||
}
|
||||
if manifest.Runtime != "codex" {
|
||||
t.Fatalf("Runtime = %q, want codex", manifest.Runtime)
|
||||
}
|
||||
entries := map[string]PromptManifestEntry{}
|
||||
for _, entry := range manifest.Entries {
|
||||
entries[entry.Name] = entry
|
||||
}
|
||||
for _, want := range []string{"anygen_svg_readme", "mode_system_prompt_svg", "svg_reference", "resolve_design_brief", "slide_outline", "activate_slides_edit", "slides_edit", "finish_slides_edit", "generate_svg_chart", "slides_convert", "slides_parse_template"} {
|
||||
if entries[want].Path == "" {
|
||||
t.Fatalf("manifest missing %q: %+v", want, manifest.Entries)
|
||||
}
|
||||
}
|
||||
if entries["anygen_svg_readme"].Path != "skills/lark-slides/references/anygen-svg/README.md" || !entries["anygen_svg_readme"].Always {
|
||||
t.Fatalf("anygen_svg_readme entry = %+v, want always README path", entries["anygen_svg_readme"])
|
||||
}
|
||||
if !entries["mode_system_prompt_svg"].Always || !entries["svg_reference"].Always {
|
||||
t.Fatalf("core prompt entries must be always available: %+v", manifest.Entries)
|
||||
}
|
||||
if entries["activate_slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("activate_slides_edit stage = %q, want %q", entries["activate_slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("slides_edit stage = %q, want %q", entries["slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["generate_svg_chart"].Stage != StageAssets {
|
||||
t.Fatalf("generate_svg_chart stage = %q, want %q", entries["generate_svg_chart"].Stage, StageAssets)
|
||||
}
|
||||
paths := strings.Join(PromptPathsForStage(StageSVGAuthor), "\n")
|
||||
for _, want := range []string{"README.md", "mode_system_prompt_svg.md", "svg_reference.md", "tools/activate_slides_edit.md", "tools/slides_edit.md", "tools/compute_custom_shape_bbox.md"} {
|
||||
if !strings.Contains(paths, want) {
|
||||
t.Fatalf("SVG author prompt paths missing %q:\n%s", want, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsBlankRequiredFields(t *testing.T) {
|
||||
blankRoot := " "
|
||||
t.Chdir(t.TempDir())
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "root", root: blankRoot, opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "title", root: "title", opts: InitOptions{Title: " \t", Input: "source.md"}},
|
||||
{name: "input", root: "input", opts: InitOptions{Title: "Demo", Input: " \t"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected blank field refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
internal/svglide/io.go
Normal file
20
internal/svglide/io.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(path, raw, 0o644)
|
||||
}
|
||||
|
||||
func writeText(path string, content string) error {
|
||||
return validate.AtomicWrite(path, []byte(content), 0o644)
|
||||
}
|
||||
431
internal/svglide/preview.go
Normal file
431
internal/svglide/preview.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const defaultPreviewPath = "preview.html"
|
||||
const previewReceiptPath = "receipts/preview.json"
|
||||
|
||||
type PreviewReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []PreviewSlideReport `json:"slides"`
|
||||
}
|
||||
|
||||
type PreviewSlideReport struct {
|
||||
Path string `json:"path"`
|
||||
Rendered bool `json:"rendered"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type previewDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []previewDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type previewDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type previewPageData struct {
|
||||
Title string
|
||||
Status string
|
||||
SlideCount int
|
||||
RenderedCount int
|
||||
Slides []previewPageSlide
|
||||
}
|
||||
|
||||
type previewPageSlide struct {
|
||||
Number int
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Role string
|
||||
KeyMessage string
|
||||
Path string
|
||||
Rendered bool
|
||||
Message string
|
||||
}
|
||||
|
||||
func WritePreview(root string) (PreviewReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return PreviewReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return writeFailedPreview(safeRoot, run, "", "deck artifact path is empty")
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q: %v", deckPath, err))
|
||||
}
|
||||
var deck previewDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := PreviewReport{Slides: make([]PreviewSlideReport, 0, len(deck.Slides))}
|
||||
pageSlides := make([]previewPageSlide, 0, len(deck.Slides))
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, pathErr := previewSlideObjectPath(slide.Path)
|
||||
pageSlide := previewPageSlide{
|
||||
Number: i + 1,
|
||||
ID: strings.TrimSpace(slide.ID),
|
||||
Title: strings.TrimSpace(slide.Title),
|
||||
Summary: strings.TrimSpace(slide.Summary),
|
||||
Role: strings.TrimSpace(slide.Role),
|
||||
KeyMessage: strings.TrimSpace(slide.KeyMessage),
|
||||
Path: slidePath,
|
||||
}
|
||||
item := PreviewSlideReport{Path: slidePath}
|
||||
if pathErr != nil {
|
||||
item.Message = pathErr.Error()
|
||||
} else if slidePath == "" {
|
||||
item.Path = "(slide)"
|
||||
pageSlide.Path = item.Path
|
||||
item.Message = "slide path must not be empty"
|
||||
} else if _, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
|
||||
item.Message = err.Error()
|
||||
} else {
|
||||
item.Rendered = true
|
||||
pageSlide.Rendered = true
|
||||
}
|
||||
pageSlide.Message = item.Message
|
||||
report.Slides = append(report.Slides, item)
|
||||
pageSlides = append(pageSlides, pageSlide)
|
||||
}
|
||||
report = normalizePreviewReport(report)
|
||||
|
||||
if err := writePreviewArtifacts(safeRoot, run, deck.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeFailedPreview(safeRoot string, run Run, path string, message string) (PreviewReport, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
path = "(deck)"
|
||||
}
|
||||
report := normalizePreviewReport(PreviewReport{
|
||||
Slides: []PreviewSlideReport{{
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}},
|
||||
})
|
||||
pageSlides := []previewPageSlide{{
|
||||
Number: 1,
|
||||
Title: "Preview failed",
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}}
|
||||
if err := writePreviewArtifacts(safeRoot, run, run.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func normalizePreviewReport(report PreviewReport) PreviewReport {
|
||||
if report.Slides == nil {
|
||||
report.Slides = []PreviewSlideReport{}
|
||||
}
|
||||
report.Status = "passed"
|
||||
for i := range report.Slides {
|
||||
report.Slides[i].Path = strings.TrimSpace(report.Slides[i].Path)
|
||||
if report.Slides[i].Path == "" {
|
||||
report.Slides[i].Path = "(slide)"
|
||||
}
|
||||
if !report.Slides[i].Rendered {
|
||||
report.Status = "failed"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func previewSlideObjectPath(path string) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("slide path must not be empty")
|
||||
}
|
||||
if strings.Contains(raw, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(raw, "%") {
|
||||
return "", fmt.Errorf("slide path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(raw, ":") || strings.Contains(raw, "//") {
|
||||
return "", fmt.Errorf("slide path %q must be a local slides/*.svg path", raw)
|
||||
}
|
||||
parts := strings.Split(raw, "/")
|
||||
if len(parts) != 2 || parts[0] != "slides" {
|
||||
return "", fmt.Errorf("slide path %q must match slides/<file>.svg", raw)
|
||||
}
|
||||
fileName := parts[1]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("slide path %q must include a slide file name", raw)
|
||||
}
|
||||
if strings.Contains(fileName, "/") || strings.Contains(fileName, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must not contain nested directories", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("slide path %q must not contain dot segments", raw)
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(fileName)) != ".svg" {
|
||||
return "", fmt.Errorf("slide path %q must end with .svg", raw)
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(raw))
|
||||
if cleaned != raw {
|
||||
return "", fmt.Errorf("slide path %q must already be normalized", raw)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writePreviewArtifacts(safeRoot string, run Run, title string, report PreviewReport, slides []previewPageSlide) error {
|
||||
report = normalizePreviewReport(report)
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
htmlRaw, err := renderPreviewHTML(title, report, slides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(target, htmlRaw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, previewReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(receiptTarget, raw, 0o644)
|
||||
}
|
||||
|
||||
func renderPreviewHTML(title string, report PreviewReport, slides []previewPageSlide) ([]byte, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "SVGlide Preview"
|
||||
}
|
||||
var rendered int
|
||||
for _, slide := range slides {
|
||||
if slide.Rendered {
|
||||
rendered++
|
||||
}
|
||||
}
|
||||
data := previewPageData{
|
||||
Title: title,
|
||||
Status: report.Status,
|
||||
SlideCount: len(slides),
|
||||
RenderedCount: rendered,
|
||||
Slides: slides,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := previewTemplate.Execute(&b, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
var previewTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} - SVGlide Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--ink: #1f2933;
|
||||
--muted: #657286;
|
||||
--line: #d8dee8;
|
||||
--accent: #1d7a62;
|
||||
--warn: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.status.failed { background: var(--warn); }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.deck {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.slide {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 24px rgba(31,41,51,.06);
|
||||
}
|
||||
.frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
object {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.missing {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
color: var(--warn);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.details {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.details h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.label {
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
}
|
||||
.path {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.message { color: var(--warn); overflow-wrap: anywhere; }
|
||||
@media (max-width: 860px) {
|
||||
.bar { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.meta { flex-wrap: wrap; white-space: normal; }
|
||||
.slide { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="bar">
|
||||
<h1>{{.Title}}</h1>
|
||||
<div class="meta">
|
||||
<span class="status {{.Status}}">{{.Status}}</span>
|
||||
<span>{{.RenderedCount}} / {{.SlideCount}} rendered</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="deck">
|
||||
{{range .Slides}}
|
||||
<article class="slide">
|
||||
<div class="frame">
|
||||
{{if .Rendered}}
|
||||
<object data="{{.Path}}" type="image/svg+xml" aria-label="{{.Title}}"></object>
|
||||
{{else}}
|
||||
<div class="missing">{{.Message}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{printf "%02d" .Number}}. {{.Title}}</h2>
|
||||
{{if .Summary}}<div><span class="label">Summary</span><br>{{.Summary}}</div>{{end}}
|
||||
{{if .KeyMessage}}<div><span class="label">Key Message</span><br>{{.KeyMessage}}</div>{{end}}
|
||||
{{if .Role}}<div><span class="label">Role</span><br>{{.Role}}</div>{{end}}
|
||||
<div><span class="label">Path</span><br><span class="path">{{.Path}}</span></div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
299
internal/svglide/preview_test.go
Normal file
299
internal/svglide/preview_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePreviewWritesHTMLAndReceipt(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || !report.Slides[0].Rendered || report.Slides[0].Path != "slides/01.svg" {
|
||||
t.Fatalf("Slides = %+v, want rendered slides/01.svg", report.Slides)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
for _, want := range []string{"Demo - SVGlide Preview", `data="slides/01.svg"`, "01. Slide", "Key Message"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("preview.html missing %q:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "passed" || len(receipt.Slides) != 1 || !receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want passed rendered slide", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewEscapesDeckText(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDeckAt(t, filepath.Join("demo", "outline", "deck.json"), previewDeck{
|
||||
Title: `<Deck & Demo>`,
|
||||
Slides: []previewDeckSlide{{
|
||||
ID: "cover",
|
||||
Title: `<Cover & One>`,
|
||||
Summary: `Summary <script>bad()</script>`,
|
||||
Role: "cover",
|
||||
KeyMessage: `Message & context`,
|
||||
Path: "slides/01.svg",
|
||||
}},
|
||||
})
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
if _, err := WritePreview("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
if strings.Contains(html, "<script>bad()</script>") {
|
||||
t.Fatalf("preview.html contains unescaped script:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<script>bad()</script>") || !strings.Contains(html, "<Deck & Demo>") {
|
||||
t.Fatalf("preview.html missing escaped deck text:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewUsesRunArtifactDeckAndPreviewPath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
run.Artifacts.Preview = "public/deck.html"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/missing.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "public", "deck.html")); err != nil {
|
||||
t.Fatalf("missing custom preview path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); !os.IsNotExist(err) {
|
||||
t.Fatalf("default preview should not be written when artifact path is custom, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewReportsUnsafeSlidePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slidePath string
|
||||
filePath string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "escape",
|
||||
slidePath: "../outside.svg",
|
||||
filePath: "outside.svg",
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "remote scheme",
|
||||
slidePath: "https:/evil.example/a.svg",
|
||||
filePath: filepath.Join("demo", "https:", "evil.example", "a.svg"),
|
||||
wantMessage: "local slides/*.svg",
|
||||
},
|
||||
{
|
||||
name: "encoded dot segment",
|
||||
slidePath: "slides/%2e%2e.svg",
|
||||
filePath: filepath.Join("demo", "slides", "%2e%2e.svg"),
|
||||
wantMessage: "percent encoding",
|
||||
},
|
||||
{
|
||||
name: "nested directory",
|
||||
slidePath: "slides/nested/01.svg",
|
||||
filePath: filepath.Join("demo", "slides", "nested", "01.svg"),
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "backslash",
|
||||
slidePath: `slides\01.svg`,
|
||||
filePath: filepath.Join("demo", `slides\01.svg`),
|
||||
wantMessage: "forward slashes",
|
||||
},
|
||||
{
|
||||
name: "wrong extension",
|
||||
slidePath: "slides/01.png",
|
||||
filePath: filepath.Join("demo", "slides", "01.png"),
|
||||
wantMessage: ".svg",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.slidePath)
|
||||
writeValidateTestFile(t, tt.filePath, visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || report.Slides[0].Rendered {
|
||||
t.Fatalf("Slides = %+v, want unrendered slide", report.Slides)
|
||||
}
|
||||
if !strings.Contains(report.Slides[0].Message, tt.wantMessage) {
|
||||
t.Fatalf("Message = %q, want %q", report.Slides[0].Message, tt.wantMessage)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want failed unrendered slide", receipt)
|
||||
}
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(htmlRaw), `data="`) {
|
||||
t.Fatalf("preview should not embed unsafe slide path:\n%s", string(htmlRaw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewWritesFailureArtifactsForDeckReadFailures(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html for failed deck read: %v", err)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Path != "outline/deck.json" {
|
||||
t.Fatalf("preview receipt = %+v, want failed deck report", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.html")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "preview.html")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "preview.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipt symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview receipt overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptsDirectorySymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipts directory symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "preview.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("preview receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readPreviewReceipt(t *testing.T) PreviewReport {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "preview.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt PreviewReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func writeDeckAt(t *testing.T, path string, deck previewDeck) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
299
internal/svglide/prompt.go
Normal file
299
internal/svglide/prompt.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package svglide
|
||||
|
||||
func DefaultSchemas() map[string]string {
|
||||
return map[string]string{
|
||||
"request.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "input"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"input": {"type": "string"},
|
||||
"purpose": {"type": "string"},
|
||||
"audience": {"type": "string"},
|
||||
"delivery_mode": {"type": "string"},
|
||||
"language": {"type": "string"},
|
||||
"pages": {"type": "integer"},
|
||||
"visual_style_query": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"source_manifest.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "type"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["local"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"sources.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "path", "title", "excerpt", "usage", "retrieval"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"excerpt": {"type": "string"},
|
||||
"usage": {"type": "string"},
|
||||
"retrieval": {"type": "string", "enum": ["full_page", "local_file", "user_provided"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"design_brief.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["narrative_spine", "depth", "tone", "visual_system"],
|
||||
"properties": {
|
||||
"design_rationale": {"type": "string"},
|
||||
"narrative_spine": {"type": "object"},
|
||||
"depth": {"type": "object"},
|
||||
"tone": {"type": "string"},
|
||||
"visual_system": {
|
||||
"type": "object",
|
||||
"required": ["color_system", "typography", "layout_language"],
|
||||
"properties": {
|
||||
"color_system": {"type": "object"},
|
||||
"typography": {"type": "object"},
|
||||
"layout_language": {"type": "object"},
|
||||
"imagery_treatment": {"type": "object"},
|
||||
"material_texture": {"type": "object"},
|
||||
"decoration_language": {"type": "object"},
|
||||
"mood_coordinates": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"visual_system.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["color_system", "typography", "layout_language"],
|
||||
"properties": {
|
||||
"color_system": {"type": "object"},
|
||||
"typography": {"type": "object"},
|
||||
"layout_language": {"type": "object"},
|
||||
"imagery_treatment": {"type": "object"},
|
||||
"material_texture": {"type": "object"},
|
||||
"decoration_language": {"type": "object"},
|
||||
"mood_coordinates": {"type": "object"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"deck.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["main_title", "style_instruction", "slides"],
|
||||
"properties": {
|
||||
"main_title": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"style_instruction": {
|
||||
"type": "object",
|
||||
"required": ["aesthetic_direction", "color_palette", "typography"],
|
||||
"properties": {
|
||||
"aesthetic_direction": {"type": "string"},
|
||||
"color_palette": {"type": "object"},
|
||||
"typography": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "summary", "role", "key_message", "path"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"page_title": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"key_message": {"type": "string"},
|
||||
"path": {"type": "string", "pattern": "^slides/[^/]+\\.svg$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"slide_content.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides"],
|
||||
"properties": {
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "content", "source_refs", "visuals"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
"source_refs": {"type": "array", "items": {"type": "string"}},
|
||||
"visuals": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "instruction"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["image", "diagram", "icon", "chart", "table", "crop", "none"]},
|
||||
"instruction": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"assets_plan.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode", "assets"],
|
||||
"properties": {
|
||||
"mode": {"type": "string", "enum": ["experiment_unrestricted_assets"]},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "slide_id", "type", "path", "usage", "status"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"slide_id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["image", "diagram", "icon", "chart", "table", "crop"]},
|
||||
"path": {"type": "string"},
|
||||
"usage": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["ready", "missing", "deferred"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"quality.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues", "metrics"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message", "severity"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides", "sources", "web_sources", "assets", "slides_with_source_refs", "slides_with_visuals"],
|
||||
"properties": {
|
||||
"slides": {"type": "integer"},
|
||||
"sources": {"type": "integer"},
|
||||
"web_sources": {"type": "integer"},
|
||||
"assets": {"type": "integer"},
|
||||
"slides_with_source_refs": {"type": "integer"},
|
||||
"slides_with_visuals": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"receipt.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["stage", "status"],
|
||||
"properties": {
|
||||
"stage": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"artifacts": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"lint.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"preview.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "slides"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "rendered"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"rendered": {"type": "boolean"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
55
internal/svglide/prompt_manifest.go
Normal file
55
internal/svglide/prompt_manifest.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package svglide
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
const anyGenPromptRoot = "skills/lark-slides/references/anygen-svg"
|
||||
|
||||
type PromptManifest struct {
|
||||
Source string `json:"source"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entries []PromptManifestEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type PromptManifestEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Always bool `json:"always,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultPromptManifest() PromptManifest {
|
||||
return PromptManifest{
|
||||
Source: anyGenPromptRoot,
|
||||
Runtime: "codex",
|
||||
Entries: []PromptManifestEntry{
|
||||
{Name: "anygen_svg_readme", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "README.md")), Always: true},
|
||||
{Name: "mode_system_prompt_svg", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "mode_system_prompt_svg.md")), Always: true},
|
||||
{Name: "svg_reference", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svg_reference.md")), Always: true},
|
||||
{Name: "resolve_design_brief", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "resolve_design_brief.md")), Stage: StageDesignBrief},
|
||||
{Name: "slide_outline", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slide_outline.md")), Stage: StageOutline},
|
||||
{Name: "activate_slides_edit", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "activate_slides_edit.md")), Stage: StageSVGAuthor},
|
||||
{Name: "slides_edit", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slides_edit.md")), Stage: StageSVGAuthor},
|
||||
{Name: "finish_slides_edit", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "finish_slides_edit.md")), Stage: StageValidatePreviewRepair},
|
||||
{Name: "slide_organize", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slide_organize.md")), Stage: StageOutline},
|
||||
{Name: "compute_custom_shape_bbox", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "compute_custom_shape_bbox.md")), Stage: StageSVGAuthor},
|
||||
{Name: "generate_svg_chart", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "generate_svg_chart.md")), Stage: StageAssets},
|
||||
{Name: "slides_convert", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slides_convert.md"))},
|
||||
{Name: "slides_parse_template", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slides_parse_template.md"))},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func PromptPathsForStage(stage string) []string {
|
||||
manifest := DefaultPromptManifest()
|
||||
paths := make([]string, 0, len(manifest.Entries))
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Always || entry.Stage == stage {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func writePromptManifest(root string) error {
|
||||
return writeJSON(filepath.Join(root, "prompt_manifest.json"), DefaultPromptManifest())
|
||||
}
|
||||
293
internal/svglide/quality.go
Normal file
293
internal/svglide/quality.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type QualityReport struct {
|
||||
Status string `json:"status"`
|
||||
Issues []QualityIssue `json:"issues"`
|
||||
Metrics QualityMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
type QualityIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type QualityMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
Sources int `json:"sources"`
|
||||
WebSources int `json:"web_sources"`
|
||||
Assets int `json:"assets"`
|
||||
SlidesWithSourceRef int `json:"slides_with_source_refs"`
|
||||
SlidesWithVisuals int `json:"slides_with_visuals"`
|
||||
}
|
||||
|
||||
type qualitySourcesFile struct {
|
||||
Sources []qualitySource `json:"sources"`
|
||||
}
|
||||
|
||||
type qualitySource struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Usage string `json:"usage"`
|
||||
Retrieval string `json:"retrieval"`
|
||||
}
|
||||
|
||||
type qualityContentFile struct {
|
||||
Slides []qualityContentSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type qualityContentSlide struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
Visuals []qualityVisual `json:"visuals"`
|
||||
}
|
||||
|
||||
type qualityVisual struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
|
||||
type qualityAssetsFile struct {
|
||||
Assets []qualityAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type qualityAsset struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Usage string `json:"usage"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func CheckQuality(root string) (QualityReport, error) {
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
|
||||
deck, err := readAuthorDeck(safeRoot, "outline/deck.json")
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
sources, err := readQualitySources(safeRoot)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
assets, err := readQualityAssets(safeRoot)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
|
||||
report := QualityReport{
|
||||
Status: "passed",
|
||||
Issues: []QualityIssue{},
|
||||
Metrics: QualityMetrics{},
|
||||
}
|
||||
report.Metrics.Slides = len(deck.Slides)
|
||||
report.Metrics.Sources = len(sources.Sources)
|
||||
report.Metrics.Assets = len(assets.Assets)
|
||||
|
||||
sourceIDs := make(map[string]bool, len(sources.Sources))
|
||||
hasLocalOrUserProvidedSource := false
|
||||
for _, source := range sources.Sources {
|
||||
id := strings.TrimSpace(source.ID)
|
||||
if id != "" {
|
||||
sourceIDs[id] = true
|
||||
}
|
||||
retrieval := strings.TrimSpace(source.Retrieval)
|
||||
path := strings.TrimSpace(source.Path)
|
||||
if retrieval == "full_page" && (strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")) {
|
||||
report.Metrics.WebSources++
|
||||
}
|
||||
if retrieval == "local_file" || retrieval == "user_provided" {
|
||||
hasLocalOrUserProvidedSource = true
|
||||
}
|
||||
}
|
||||
if report.Metrics.WebSources == 0 && !hasLocalOrUserProvidedSource {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"research/sources.json",
|
||||
"svglide.quality.research",
|
||||
"topic decks need at least one full_page web source or explicit local/user-provided source",
|
||||
))
|
||||
}
|
||||
|
||||
assetsBySlideAndID := make(map[string]qualityAsset, len(assets.Assets))
|
||||
deferredBySlideAndID := make(map[string]qualityAsset, len(assets.Assets))
|
||||
for _, asset := range assets.Assets {
|
||||
status := strings.TrimSpace(asset.Status)
|
||||
key := strings.TrimSpace(asset.SlideID) + "/" + strings.TrimSpace(asset.ID)
|
||||
if status == "deferred" {
|
||||
deferredBySlideAndID[key] = asset
|
||||
continue
|
||||
}
|
||||
if status != "ready" {
|
||||
continue
|
||||
}
|
||||
assetsBySlideAndID[key] = asset
|
||||
}
|
||||
|
||||
contentByID := make(map[string]qualityContentSlide, len(content.Slides))
|
||||
for _, slide := range content.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
contentByID[id] = slide
|
||||
}
|
||||
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
item, ok := contentByID[id]
|
||||
if !ok {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.content",
|
||||
fmt.Sprintf("deck slide %q is missing content", id),
|
||||
))
|
||||
continue
|
||||
}
|
||||
if len(item.SourceRefs) > 0 {
|
||||
report.Metrics.SlidesWithSourceRef++
|
||||
} else {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.source_refs",
|
||||
fmt.Sprintf("slide %q has no source_refs", id),
|
||||
))
|
||||
}
|
||||
for _, ref := range item.SourceRefs {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" || !sourceIDs[ref] {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.source_refs",
|
||||
fmt.Sprintf("slide %q references unknown source %q", id, ref),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if len(item.Visuals) == 0 {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.visuals",
|
||||
fmt.Sprintf("slide %q has no visuals; use a type=none sentinel when no visual asset is needed", id),
|
||||
))
|
||||
}
|
||||
|
||||
hasVisual := false
|
||||
for _, visual := range item.Visuals {
|
||||
visualType := strings.TrimSpace(visual.Type)
|
||||
if visualType == "none" {
|
||||
continue
|
||||
}
|
||||
hasVisual = true
|
||||
key := id + "/" + strings.TrimSpace(visual.ID)
|
||||
asset, ok := assetsBySlideAndID[key]
|
||||
if !ok && visualTypeIsDeferredOnly(visualType) {
|
||||
if deferredAsset, deferred := deferredBySlideAndID[key]; deferred {
|
||||
asset = deferredAsset
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"assets/assets_plan.json",
|
||||
"svglide.quality.asset",
|
||||
fmt.Sprintf("slide %q visual %q has no ready asset", id, visual.ID),
|
||||
))
|
||||
continue
|
||||
}
|
||||
assetType := strings.TrimSpace(asset.Type)
|
||||
if assetType != visualType {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"assets/assets_plan.json",
|
||||
"svglide.quality.asset",
|
||||
fmt.Sprintf("slide %q visual %q type %q has ready asset type %q", id, visual.ID, visualType, assetType),
|
||||
))
|
||||
}
|
||||
}
|
||||
if hasVisual {
|
||||
report.Metrics.SlidesWithVisuals++
|
||||
}
|
||||
}
|
||||
|
||||
if len(report.Issues) > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
|
||||
if err := writeJSON(filepath.Join(safeRoot, "quality_report.json"), report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func visualTypeIsDeferredOnly(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "chart", "table", "crop":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func readQualitySources(safeRoot string) (qualitySourcesFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "research/sources.json")
|
||||
if err != nil {
|
||||
return qualitySourcesFile{}, err
|
||||
}
|
||||
var file qualitySourcesFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return qualitySourcesFile{}, fmt.Errorf("read sources %q: %w", "research/sources.json", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func readQualityContent(safeRoot string) (qualityContentFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return qualityContentFile{}, err
|
||||
}
|
||||
var file qualityContentFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return qualityContentFile{}, fmt.Errorf("read slide content %q: %w", "content/slide_content.json", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func readQualityAssets(safeRoot string) (qualityAssetsFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "assets/assets_plan.json")
|
||||
if err != nil {
|
||||
return qualityAssetsFile{}, err
|
||||
}
|
||||
var file qualityAssetsFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return qualityAssetsFile{}, fmt.Errorf("read assets plan %q: %w", "assets/assets_plan.json", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func qualityIssue(path, code, message string) QualityIssue {
|
||||
return QualityIssue{
|
||||
Path: path,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
}
|
||||
}
|
||||
289
internal/svglide/quality_test.go
Normal file
289
internal/svglide/quality_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckQualityAllowsExplicitLocalSourceWithoutFullPageWebSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"source.md","title":"Local Source","excerpt":"Input","usage":"Support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["local1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsTopicDeckWithoutFullPageWebOrExplicitLocalSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"source1","path":"source.md","title":"Weak Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["source1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.research") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.research", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsSlideContentWithoutSourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":[],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.source_refs") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.source_refs", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingVisualAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityPassesAnyGenReadyRun(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
if report.Metrics.Slides != 1 || report.Metrics.Sources != 1 || report.Metrics.WebSources != 1 || report.Metrics.Assets != 1 || report.Metrics.SlidesWithSourceRef != 1 || report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics = %+v, want all ones", report.Metrics)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
var written QualityReport
|
||||
if err := json.Unmarshal(raw, &written); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written.Status != "passed" {
|
||||
t.Fatalf("written status = %q, want passed", written.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsExperimentAssetsAndDeferredUnsupportedVisuals(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Full page excerpt","usage":"evidence","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"Chart claim","summary":"Needs chart later","role":"content","key_message":"Chart is deferred","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Chart-backed point","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a remote hero image"},{"id":"chart1","type":"chart","instruction":"Use a real chart when chart generation is enabled"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"},{"id":"chart1","slide_id":"s1","type":"chart","path":"","usage":"Deferred chart generation","status":"deferred"}]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsAbsoluteReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsEmptyVisuals(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visuals") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.visuals", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsVisualAssetTypeMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityCountsSlidesWithVisualsPerPage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"},{"id":"logo","type":"diagram","instruction":"Support diagram"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"},{"id":"logo","slide_id":"s1","type":"diagram","path":"assets/images/logo.svg","usage":"Support diagram","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "logo.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics.slides_with_visuals = %d, want 1", report.Metrics.SlidesWithVisuals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsSymlinkReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("outside-hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityUsesOutlineDeckNotRunArtifactDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeStatusTestRunFile(t, run)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "custom"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/custom/deck.json", `{"title":"Custom Deck","slides":[{"id":"c1","title":"Custom 1","summary":"Custom summary 1","role":"cover","key_message":"Custom key 1","path":"slides/01.svg"},{"id":"c2","title":"Custom 2","summary":"Custom summary 2","role":"content","key_message":"Custom key 2","path":"slides/02.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]},{"id":"c1","content":"Custom claim 1","source_refs":["web1"],"visuals":[{"id":"v2","type":"none","instruction":"Text-only"}]},{"id":"c2","content":"Custom claim 2","source_refs":["web1"],"visuals":[{"id":"v3","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.Slides != 1 {
|
||||
t.Fatalf("metrics.slides = %d, want 1 from outline/deck.json", report.Metrics.Slides)
|
||||
}
|
||||
}
|
||||
|
||||
func qualityIssueCodesContain(issues []QualityIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
153
internal/svglide/receipt.go
Normal file
153
internal/svglide/receipt.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type validationLintReceipt struct {
|
||||
Status string `json:"status"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
func writeValidationArtifacts(safeRoot string, report ValidationReport) error {
|
||||
report = normalizeValidationReport(report)
|
||||
lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(validationLintReceipt{
|
||||
Status: validationReceiptStatus(report),
|
||||
Issues: report.Issues,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644)
|
||||
}
|
||||
|
||||
func normalizeValidationReport(report ValidationReport) ValidationReport {
|
||||
if report.Issues == nil {
|
||||
report.Issues = []ValidationIssue{}
|
||||
}
|
||||
report.OK = len(report.Issues) == 0
|
||||
for i := range report.Issues {
|
||||
report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path)
|
||||
if report.Issues[i].Path == "" {
|
||||
report.Issues[i].Path = "(deck)"
|
||||
}
|
||||
report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code)
|
||||
if report.Issues[i].Code == "" {
|
||||
report.Issues[i].Code = "svglide.validation"
|
||||
}
|
||||
report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity)
|
||||
if report.Issues[i].Severity == "" {
|
||||
report.Issues[i].Severity = "error"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func validationReceiptStatus(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "passed"
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
|
||||
func renderRepairQueue(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "No repair needed.\n"
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Repair Queue\n\n")
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) {
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return "", fmt.Errorf("run file path must not be root")
|
||||
}
|
||||
dirRel := filepath.Dir(cleanRel)
|
||||
if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := safeRunPath(safeRoot, cleanRel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run file path %q must not be a symlink", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("run file path %q must be a regular file", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return path, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err := vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := vfs.Mkdir(cur, 0o755); err != nil {
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
142
internal/svglide/repair.go
Normal file
142
internal/svglide/repair.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RepairReport struct {
|
||||
Status string `json:"status"`
|
||||
LintOK bool `json:"lint_ok"`
|
||||
Preview string `json:"preview"`
|
||||
Quality string `json:"quality"`
|
||||
Reauthored bool `json:"reauthored"`
|
||||
}
|
||||
|
||||
func RepairRun(root string) (RepairReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
lint, validateErr := ValidateRun(root)
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
|
||||
reauthored := false
|
||||
if !lint.OK {
|
||||
repairPaths, ok := authorRepairPaths(lint)
|
||||
if ok {
|
||||
if _, err := authorSlides(root, repairPaths); err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
reauthored = true
|
||||
lint, validateErr = ValidateRun(root)
|
||||
}
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
}
|
||||
|
||||
preview, err := WritePreview(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
quality, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
report := RepairReport{
|
||||
Status: "failed",
|
||||
LintOK: lint.OK,
|
||||
Preview: preview.Status,
|
||||
Quality: quality.Status,
|
||||
Reauthored: reauthored,
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" {
|
||||
report.Status = "passed"
|
||||
}
|
||||
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: StageValidatePreviewRepair,
|
||||
Status: report.Status,
|
||||
Message: repairReceiptMessage(report),
|
||||
Artifacts: []string{
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"quality_report.json",
|
||||
"repair_queue.md",
|
||||
previewPath,
|
||||
},
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func canRepairByAuthoring(report ValidationReport) bool {
|
||||
_, ok := authorRepairPaths(report)
|
||||
return ok
|
||||
}
|
||||
|
||||
func authorRepairPaths(report ValidationReport) (map[string]bool, bool) {
|
||||
if report.OK || len(report.Issues) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, issue := range report.Issues {
|
||||
path, ok := repairIssueAuthorPath(issue)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
paths[path] = true
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return paths, true
|
||||
}
|
||||
|
||||
func canRepairIssueByAuthoring(issue ValidationIssue) bool {
|
||||
_, ok := repairIssueAuthorPath(issue)
|
||||
return ok
|
||||
}
|
||||
|
||||
func repairIssueAuthorPath(issue ValidationIssue) (string, bool) {
|
||||
path := strings.TrimSpace(issue.Path)
|
||||
slidePath, err := previewSlideObjectPath(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(issue.Code) {
|
||||
case "svglide.path":
|
||||
return slidePath, strings.Contains(issue.Message, "missing or not a regular file")
|
||||
case "svglide.xml", "svglide.root", "svglide.slide_role", "svglide.viewbox", "svglide.visible_content":
|
||||
return slidePath, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func repairReceiptMessage(report RepairReport) string {
|
||||
if report.Status == "passed" {
|
||||
if report.Reauthored {
|
||||
return "lint, preview, and quality passed after reauthoring"
|
||||
}
|
||||
return "lint, preview, and quality passed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality != "passed" {
|
||||
return "quality gate failed"
|
||||
}
|
||||
if report.Reauthored {
|
||||
return "repair reauthored slides but lint or preview still failed"
|
||||
}
|
||||
return "lint or preview failed"
|
||||
}
|
||||
241
internal/svglide/repair_test.go
Normal file
241
internal/svglide/repair_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepairRunAuthorsMissingSlidesAndWritesFinalReceipt(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if !report.LintOK {
|
||||
t.Fatalf("LintOK = false, want true: %+v", report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "passed" {
|
||||
t.Fatalf("Quality = %q, want passed: %+v", report.Quality, report)
|
||||
}
|
||||
if !report.Reauthored {
|
||||
t.Fatalf("Reauthored = false, want true: %+v", report)
|
||||
}
|
||||
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"quality_report.json",
|
||||
"receipts/validate_preview_repair.json",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["stage"] != StageValidatePreviewRepair {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageValidatePreviewRepair)
|
||||
}
|
||||
if receipt["status"] != "passed" {
|
||||
t.Fatalf("receipt status = %v, want passed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "lint, preview, and quality passed after reauthoring" {
|
||||
t.Fatalf("receipt message = %v, want quality-aware pass message", receipt["message"])
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["updated_at"]; ok {
|
||||
t.Fatalf("receipt contains updated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunFailsWhenQualityFails(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"research/local.md","title":"Local source","excerpt":"Local excerpt","usage":"support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.LintOK != true {
|
||||
t.Fatalf("LintOK = %v, want true: %+v", report.LintOK, report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "failed" {
|
||||
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
|
||||
}
|
||||
|
||||
qualityRaw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var quality map[string]any
|
||||
if err := json.Unmarshal(qualityRaw, &quality); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality["status"] != "failed" {
|
||||
t.Fatalf("quality status = %v, want failed: %+v", quality["status"], quality)
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["status"] != "failed" {
|
||||
t.Fatalf("receipt status = %v, want failed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "quality gate failed" {
|
||||
t.Fatalf("receipt message = %v, want quality gate failed", receipt["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairReceiptMessagePrioritizesLintPreviewFailuresOverQuality(t *testing.T) {
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed"}); got != "lint or preview failed" {
|
||||
t.Fatalf("message = %q, want lint or preview failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed", Reauthored: true}); got != "repair reauthored slides but lint or preview still failed" {
|
||||
t.Fatalf("reauthored message = %q, want reauthored lint/preview failure", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "failed"}); got != "quality gate failed" {
|
||||
t.Fatalf("quality-only message = %q, want quality gate failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunOnlyReauthorsFailedSlidePaths(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
custom := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">KEEP-CUSTOM-01</text></svg>`
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", custom)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != custom {
|
||||
t.Fatalf("slides/01.svg was overwritten:\n%s", string(raw))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "02.svg")); err != nil {
|
||||
t.Fatalf("missing reauthored slides/02.svg: %v", err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunReauthorsBackgroundOnlySVG(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", backgroundOnlySVG())
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunDoesNotAuthorInvalidSlidePath(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/../01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.Reauthored {
|
||||
t.Fatalf("Reauthored = true, want false for invalid path: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("svg_author receipt exists or stat failed, want no authoring: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunTreatsValidationArtifactWriteErrorAsFatal(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
if err := os.Remove(filepath.Join("demo", "repair_queue.md")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join("demo", "repair_queue.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := RepairRun("demo"); err == nil {
|
||||
t.Fatal("expected repair to return validation artifact write error")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("final repair receipt exists or stat failed, want no misleading final receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readRepairReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "validate_preview_repair.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
120
internal/svglide/run.go
Normal file
120
internal/svglide/run.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package svglide
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StageRequest = "request"
|
||||
StageResearch = "research"
|
||||
StageDesignBrief = "design_brief"
|
||||
StageOutline = "outline"
|
||||
StageSlideContent = "slide_content"
|
||||
StageAssets = "assets"
|
||||
StageSVGAuthor = "svg_author"
|
||||
StageValidatePreviewRepair = "validate_preview_repair"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusReady = "ready"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusDone = "done"
|
||||
StatusFailed = "failed"
|
||||
StatusBlocked = "blocked"
|
||||
StatusNeedsRepair = "needs_repair"
|
||||
)
|
||||
|
||||
type Run struct {
|
||||
Version int `json:"version"`
|
||||
Runtime string `json:"runtime"`
|
||||
Command string `json:"command"`
|
||||
Title string `json:"title"`
|
||||
Input string `json:"input"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
Out string `json:"out"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Stages []Stage `json:"stages"`
|
||||
Artifacts ArtifactPaths `json:"artifacts"`
|
||||
Policy Policy `json:"policy"`
|
||||
}
|
||||
|
||||
type Stage struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
Receipt string `json:"receipt"`
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
Deck string `json:"deck"`
|
||||
SlidesDir string `json:"slides_dir"`
|
||||
Preview string `json:"preview"`
|
||||
RepairQueue string `json:"repair_queue"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
PublishEnabled bool `json:"publish_enabled"`
|
||||
NetworkByCodex bool `json:"network_by_codex"`
|
||||
ImageGenerationByCodex bool `json:"image_generation_by_codex"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type NewRunConfig struct {
|
||||
Title string
|
||||
Input string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Out string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func NewRun(cfg NewRunConfig) Run {
|
||||
now := cfg.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
ts := now.Format(time.RFC3339)
|
||||
return Run{
|
||||
Version: 1,
|
||||
Runtime: "codex",
|
||||
Command: "slides +create-svglide",
|
||||
Title: cfg.Title,
|
||||
Input: cfg.Input,
|
||||
Audience: cfg.Audience,
|
||||
DeliveryMode: cfg.DeliveryMode,
|
||||
Pages: cfg.Pages,
|
||||
Out: cfg.Out,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentStage: StageRequest,
|
||||
Stages: DefaultStages(),
|
||||
Artifacts: ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
},
|
||||
Policy: Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByCodex: true,
|
||||
ImageGenerationByCodex: true,
|
||||
Overwrite: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultStages() []Stage {
|
||||
return []Stage{
|
||||
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
|
||||
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"},
|
||||
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Receipt: "receipts/design_brief.json"},
|
||||
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
|
||||
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json"}, Receipt: "receipts/slide_content.json"},
|
||||
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/assets_plan.json"}, Receipt: "receipts/assets.json"},
|
||||
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
|
||||
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "quality_report.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"},
|
||||
}
|
||||
}
|
||||
174
internal/svglide/run_test.go
Normal file
174
internal/svglide/run_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultStagesAreOrdered(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
want := []string{
|
||||
StageRequest,
|
||||
StageResearch,
|
||||
StageDesignBrief,
|
||||
StageOutline,
|
||||
StageSlideContent,
|
||||
StageAssets,
|
||||
StageSVGAuthor,
|
||||
StageValidatePreviewRepair,
|
||||
}
|
||||
if len(stages) != len(want) {
|
||||
t.Fatalf("stage count = %d, want %d", len(stages), len(want))
|
||||
}
|
||||
for i, stage := range stages {
|
||||
if stage.Name != want[i] {
|
||||
t.Fatalf("stage[%d] = %q, want %q", i, stage.Name, want[i])
|
||||
}
|
||||
if stage.Status != StatusPending {
|
||||
t.Fatalf("stage[%d].Status = %q, want %q", i, stage.Status, StatusPending)
|
||||
}
|
||||
if stage.Inputs == nil {
|
||||
t.Fatalf("stage[%d].Inputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Outputs == nil {
|
||||
t.Fatalf("stage[%d].Outputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Receipt == "" {
|
||||
t.Fatalf("stage[%d] missing receipt path", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
svgAuthor := mustStage(t, stages, StageSVGAuthor)
|
||||
if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
|
||||
}
|
||||
repair := mustStage(t, stages, StageValidatePreviewRepair)
|
||||
if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesFinalStageRequiresQualityReport(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
final := stages[len(stages)-1]
|
||||
if final.Name != StageValidatePreviewRepair {
|
||||
t.Fatalf("final stage = %q, want %q", final.Name, StageValidatePreviewRepair)
|
||||
}
|
||||
if !stringSliceContains(final.Outputs, "quality_report.json") {
|
||||
t.Fatalf("final outputs = %+v, want quality_report.json", final.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
research := mustStage(t, stages, StageResearch)
|
||||
want := []string{"request/request.json", "request/source_manifest.json"}
|
||||
if !reflect.DeepEqual(research.Inputs, want) {
|
||||
t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
outline := mustStage(t, stages, StageOutline)
|
||||
want := []string{"brief/design_brief.json", "brief/visual_system.json"}
|
||||
if !reflect.DeepEqual(outline.Inputs, want) {
|
||||
t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDefaultsToCodexRuntime(t *testing.T) {
|
||||
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品和工程负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Out: ".lark-slides/svglide-runs/demo",
|
||||
Now: now,
|
||||
})
|
||||
if run.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", run.Version)
|
||||
}
|
||||
if run.Runtime != "codex" {
|
||||
t.Fatalf("Runtime = %q, want codex", run.Runtime)
|
||||
}
|
||||
if run.Command != "slides +create-svglide" {
|
||||
t.Fatalf("Command = %q, want slides +create-svglide", run.Command)
|
||||
}
|
||||
if run.Title != "Demo" {
|
||||
t.Fatalf("Title = %q, want Demo", run.Title)
|
||||
}
|
||||
if run.Input != "source.md" {
|
||||
t.Fatalf("Input = %q, want source.md", run.Input)
|
||||
}
|
||||
if run.Audience != "产品和工程负责人" {
|
||||
t.Fatalf("Audience = %q, want 产品和工程负责人", run.Audience)
|
||||
}
|
||||
if run.DeliveryMode != "self_read" {
|
||||
t.Fatalf("DeliveryMode = %q, want self_read", run.DeliveryMode)
|
||||
}
|
||||
if run.Pages != 8 {
|
||||
t.Fatalf("Pages = %d, want 8", run.Pages)
|
||||
}
|
||||
if run.Out != ".lark-slides/svglide-runs/demo" {
|
||||
t.Fatalf("Out = %q, want .lark-slides/svglide-runs/demo", run.Out)
|
||||
}
|
||||
wantTS := now.Format(time.RFC3339)
|
||||
if run.CreatedAt != wantTS {
|
||||
t.Fatalf("CreatedAt = %q, want %q", run.CreatedAt, wantTS)
|
||||
}
|
||||
if run.UpdatedAt != wantTS {
|
||||
t.Fatalf("UpdatedAt = %q, want %q", run.UpdatedAt, wantTS)
|
||||
}
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
wantArtifacts := ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
}
|
||||
if run.Artifacts != wantArtifacts {
|
||||
t.Fatalf("Artifacts = %+v, want %+v", run.Artifacts, wantArtifacts)
|
||||
}
|
||||
wantStages := DefaultStages()
|
||||
if !reflect.DeepEqual(run.Stages, wantStages) {
|
||||
t.Fatalf("Stages = %+v, want %+v", run.Stages, wantStages)
|
||||
}
|
||||
wantPolicy := Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByCodex: true,
|
||||
ImageGenerationByCodex: true,
|
||||
Overwrite: false,
|
||||
}
|
||||
if run.Policy != wantPolicy {
|
||||
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStage(t *testing.T, stages []Stage, name string) Stage {
|
||||
t.Helper()
|
||||
for _, stage := range stages {
|
||||
if stage.Name == name {
|
||||
return stage
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return Stage{}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
304
internal/svglide/schema.go
Normal file
304
internal/svglide/schema.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type liteJSONSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
AdditionalProperties *bool `json:"additionalProperties"`
|
||||
Properties map[string]liteJSONSchema `json:"properties"`
|
||||
Items *liteJSONSchema `json:"items"`
|
||||
MinItems *int `json:"minItems"`
|
||||
Enum []string `json:"enum"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
var stageOutputSchemaPaths = map[string]string{
|
||||
"request/request.json": "schemas/request.schema.json",
|
||||
"request/source_manifest.json": "schemas/source_manifest.schema.json",
|
||||
"research/sources.json": "schemas/sources.schema.json",
|
||||
"brief/design_brief.json": "schemas/design_brief.schema.json",
|
||||
"brief/visual_system.json": "schemas/visual_system.schema.json",
|
||||
"outline/deck.json": "schemas/deck.schema.json",
|
||||
"content/slide_content.json": "schemas/slide_content.schema.json",
|
||||
"assets/assets_plan.json": "schemas/assets_plan.schema.json",
|
||||
"quality_report.json": "schemas/quality.schema.json",
|
||||
"receipts/lint.json": "schemas/lint.schema.json",
|
||||
"receipts/preview.json": "schemas/preview.schema.json",
|
||||
}
|
||||
|
||||
func ValidateStageOutputs(root string) error {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, output := range stage.Outputs {
|
||||
if hasGlobMeta(output) || strings.ToLower(filepath.Ext(output)) != ".json" {
|
||||
continue
|
||||
}
|
||||
schemaPath, ok := stageOutputSchemaPaths[output]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateStageOutputSchema(safeRoot, output, schemaPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if output == "outline/deck.json" {
|
||||
if err := validateDeckSlideOutputPaths(safeRoot, output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeckSlideOutputPaths(safeRoot string, artifactPath string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
var deck struct {
|
||||
Slides []struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
if _, err := previewSlideObjectPath(slide.Path); err != nil {
|
||||
return fmt.Errorf("%s: field slides[%d].path: %w", artifactPath, i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStageOutputSchema(safeRoot, artifactPath, schemaPath string) error {
|
||||
artifactRaw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
schemaRaw, err := readRunRegularArtifact(safeRoot, schemaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
schema, err := decodeLiteJSONSchema(schemaRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
value, err := decodeJSONValue(artifactRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
if err := validateJSONValue(schema, value, ""); err != nil {
|
||||
return fmt.Errorf("%s: %w", artifactPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLiteJSONSchema(raw []byte) (liteJSONSchema, error) {
|
||||
var schema liteJSONSchema
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if err := decoder.Decode(&schema); err != nil {
|
||||
return liteJSONSchema{}, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return liteJSONSchema{}, err
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func decodeJSONValue(raw []byte) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func rejectTrailingJSON(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("contains trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONValue(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
switch schema.Type {
|
||||
case "":
|
||||
return nil
|
||||
case "object":
|
||||
return validateJSONObject(schema, value, fieldPath)
|
||||
case "array":
|
||||
return validateJSONArray(schema, value, fieldPath)
|
||||
case "string":
|
||||
return validateJSONString(schema, value, fieldPath)
|
||||
case "integer":
|
||||
if !isJSONInteger(value) {
|
||||
return fmt.Errorf("field %s expected integer, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("field %s expected boolean, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("field %s uses unsupported schema type %q", displayFieldPath(fieldPath), schema.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONObject(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected object, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
for _, required := range schema.Required {
|
||||
if _, ok := object[required]; !ok {
|
||||
return fmt.Errorf("field %s is required", joinFieldPath(fieldPath, required))
|
||||
}
|
||||
}
|
||||
if schema.AdditionalProperties != nil && !*schema.AdditionalProperties {
|
||||
for name := range object {
|
||||
if _, ok := schema.Properties[name]; !ok {
|
||||
return fmt.Errorf("field %s is not allowed by additionalProperties:false", joinFieldPath(fieldPath, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, propertySchema := range schema.Properties {
|
||||
child, ok := object[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateJSONValue(propertySchema, child, joinFieldPath(fieldPath, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONArray(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
array, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected array, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if schema.MinItems != nil && len(array) < *schema.MinItems {
|
||||
return fmt.Errorf("field %s has %d items, want minItems %d", displayFieldPath(fieldPath), len(array), *schema.MinItems)
|
||||
}
|
||||
if schema.Items == nil {
|
||||
return nil
|
||||
}
|
||||
for i, item := range array {
|
||||
if err := validateJSONValue(*schema.Items, item, joinArrayFieldPath(fieldPath, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONString(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected string, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if len(schema.Enum) > 0 {
|
||||
for _, allowed := range schema.Enum {
|
||||
if text == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("field %s value %q is not in enum %v", displayFieldPath(fieldPath), text, schema.Enum)
|
||||
}
|
||||
if schema.Pattern != "" {
|
||||
matched, err := regexp.MatchString(schema.Pattern, text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %s has invalid pattern %q: %w", displayFieldPath(fieldPath), schema.Pattern, err)
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("field %s value %q does not match pattern %q", displayFieldPath(fieldPath), text, schema.Pattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isJSONInteger(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return isCanonicalJSONInteger(typed.String())
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isCanonicalJSONInteger(value string) bool {
|
||||
if value == "" || strings.ContainsAny(value, ".eE") {
|
||||
return false
|
||||
}
|
||||
var parsed big.Int
|
||||
_, ok := parsed.SetString(value, 10)
|
||||
return ok
|
||||
}
|
||||
|
||||
func jsonValueType(value any) string {
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
case []any:
|
||||
return "array"
|
||||
case string:
|
||||
return "string"
|
||||
case json.Number, float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return fmt.Sprintf("%T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func joinFieldPath(parent, name string) string {
|
||||
if parent == "" {
|
||||
return name
|
||||
}
|
||||
return parent + "." + name
|
||||
}
|
||||
|
||||
func joinArrayFieldPath(parent string, index int) string {
|
||||
if parent == "" {
|
||||
return fmt.Sprintf("[%d]", index)
|
||||
}
|
||||
return fmt.Sprintf("%s[%d]", parent, index)
|
||||
}
|
||||
|
||||
func displayFieldPath(path string) string {
|
||||
if path == "" {
|
||||
return "$"
|
||||
}
|
||||
return path
|
||||
}
|
||||
326
internal/svglide/schema_test.go
Normal file
326
internal/svglide/schema_test.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateStageOutputsRejectsMissingRequiredField(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(`{"title":"Demo"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "input") {
|
||||
t.Fatalf("error = %v, want path and missing field", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsCurrentRequestArtifacts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsDeckSlidePathsThatPreviewRejects(t *testing.T) {
|
||||
for _, path := range []string{"slides/a%20.svg", "slides/.hidden.svg", "slides/a..b.svg", "slides/a:b.svg"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON(path))
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outline/deck.json") || !strings.Contains(err.Error(), "slides[0].path") {
|
||||
t.Fatalf("error = %v, want deck path context", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidDeckSlidePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON("slides/a%20.svg"))
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageOutline {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageOutline)
|
||||
}
|
||||
if got := stageStatus(t, run, StageOutline); got == StatusDone {
|
||||
t.Fatalf("outline stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "outline.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("outline receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidValidatePreviewRepairReceipts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "lint",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"failed"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/lint.json",
|
||||
},
|
||||
{
|
||||
name: "preview",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","slides":[{"path":"slides/01.svg","rendered":"yes"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/preview.json",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
tt.setup(t)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.path) {
|
||||
t.Fatalf("error = %v, want path %s", err, tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidQualityReportSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","slides":[{"path":"slides/01.svg","rendered":true}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(`{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected quality report schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") {
|
||||
t.Fatalf("error = %v, want path quality_report.json", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSourcesMissingRetrieval(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
if err := os.WriteFile(filepath.Join("demo", "research", "sources.json"), []byte(`{"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"supporting evidence"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected retrieval schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "research/sources.json") || !strings.Contains(err.Error(), "retrieval") {
|
||||
t.Fatalf("error = %v, want research/sources.json and retrieval", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSlideContentMissingSourceRefsOrVisualIds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing source_refs",
|
||||
raw: `{"slides":[{"id":"s1","content":"Plan","visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "source_refs",
|
||||
},
|
||||
{
|
||||
name: "missing visual id",
|
||||
raw: `{"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[{"type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "visuals[0].id",
|
||||
},
|
||||
{
|
||||
name: "empty visuals",
|
||||
raw: `{"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[]}]}`,
|
||||
want: "visuals",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
if err := os.WriteFile(filepath.Join("demo", "content", "slide_content.json"), []byte(tt.raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected slide content schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "content/slide_content.json") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want content/slide_content.json and %s", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsAssetsMissingStatus(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"https://example.com/a.png","usage":"hero image"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected asset schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "status") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json and status", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsExperimentAssetPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "outside images", path: "../a.png"},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"`+tt.path+`","usage":"hero image","status":"ready"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected experiment asset path to pass schema validation, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSchemasIncludeAnyGenQualityContracts(t *testing.T) {
|
||||
schemas := DefaultSchemas()
|
||||
for _, name := range []string{
|
||||
"sources.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"quality.schema.json",
|
||||
} {
|
||||
if strings.TrimSpace(schemas[name]) == "" {
|
||||
t.Fatalf("schema %s is missing", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["sources.schema.json"], `"retrieval"`) {
|
||||
t.Fatalf("sources schema missing retrieval contract: %s", schemas["sources.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"source_refs"`) {
|
||||
t.Fatalf("slide content schema missing source_refs: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"visuals"`) {
|
||||
t.Fatalf("slide content schema missing visuals: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], `"slide_id"`) {
|
||||
t.Fatalf("assets schema missing slide_id: %s", schemas["assets_plan.schema.json"])
|
||||
}
|
||||
for _, want := range []string{`"experiment_unrestricted_assets"`, `"chart"`, `"table"`, `"crop"`, `"deferred"`} {
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], want) {
|
||||
t.Fatalf("assets schema missing %s: %s", want, schemas["assets_plan.schema.json"])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["quality.schema.json"], `"metrics"`) {
|
||||
t.Fatalf("quality schema missing metrics: %s", schemas["quality.schema.json"])
|
||||
}
|
||||
}
|
||||
|
||||
func validSchemaDeckJSON(path string) string {
|
||||
return `{"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"` + path + `"}]}`
|
||||
}
|
||||
|
||||
func validQualityReportJSON() string {
|
||||
return `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":1}}`
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsNonCanonicalIntegers(t *testing.T) {
|
||||
for _, pages := range []string{"8.0", "8e0", "0.99999999999999999"} {
|
||||
t.Run(pages, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
raw := `{"title":"Demo","input":"source.md","pages":` + pages + `}`
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "pages") {
|
||||
t.Fatalf("error = %v, want path and pages field", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidCurrentStageOutputSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "source_manifest.json"), []byte(`{"sources":[{"path":"source.md","type":"remote"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "request.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
120
internal/svglide/stage.go
Normal file
120
internal/svglide/stage.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StageReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
func CompleteCurrentStage(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
index, stage, err := currentStageWithIndex(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if len(missingOutputs) > 0 {
|
||||
return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", "))
|
||||
}
|
||||
|
||||
if err := ValidateStageOutputs(root); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
if err := validateFinalStageReceiptsPassed(safeRoot); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: stage.Name,
|
||||
Status: StatusDone,
|
||||
Artifacts: stage.Outputs,
|
||||
}); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
|
||||
run.Stages[index].Status = StatusDone
|
||||
if index < len(run.Stages)-1 {
|
||||
nextStage := &run.Stages[index+1]
|
||||
run.CurrentStage = nextStage.Name
|
||||
if nextStage.Status == "" {
|
||||
nextStage.Status = StatusPending
|
||||
}
|
||||
} else {
|
||||
run.CurrentStage = stage.Name
|
||||
}
|
||||
run.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
if err := writeRunFile(safeRoot, run); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return InspectStatus(root)
|
||||
}
|
||||
|
||||
type stageStatusReceipt struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func validateFinalStageReceiptsPassed(safeRoot string) error {
|
||||
for _, path := range []string{"receipts/lint.json", "receipts/preview.json", "quality_report.json"} {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read receipt: %w", path, err)
|
||||
}
|
||||
var receipt stageStatusReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", path, err)
|
||||
}
|
||||
if receipt.Status != "passed" {
|
||||
return fmt.Errorf("%s: status is %q, want passed", path, receipt.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentStageWithIndex(run Run) (int, Stage, error) {
|
||||
for i, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return i, stage, nil
|
||||
}
|
||||
}
|
||||
return -1, Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func writeRunFile(safeRoot string, run Run) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, "run.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, run)
|
||||
}
|
||||
|
||||
func writeStageReceipt(safeRoot string, receipt StageReceipt) error {
|
||||
if strings.TrimSpace(receipt.Stage) == "" {
|
||||
return fmt.Errorf("stage receipt stage must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(receipt.Stage, `/\`) || receipt.Stage == "." || receipt.Stage == ".." {
|
||||
return fmt.Errorf("stage receipt stage %q must be a file name", receipt.Stage)
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, filepath.Join("receipts", receipt.Stage+".json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, receipt)
|
||||
}
|
||||
148
internal/svglide/stage_test.go
Normal file
148
internal/svglide/stage_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompleteCurrentStageAdvancesToNextStage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageResearch {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageResearch {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageResearch)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got != StatusDone {
|
||||
t.Fatalf("request stage status = %q, want %q", got, StatusDone)
|
||||
}
|
||||
if got := stageStatus(t, run, StageResearch); got != StatusPending {
|
||||
t.Fatalf("research stage status = %q, want %q", got, StatusPending)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt StageReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt.Stage != StageRequest || receipt.Status != StatusDone {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsMissingOutput(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing output error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageDoesNotAdvanceRunWhenReceiptWriteFails(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Mkdir(filepath.Join("demo", "receipts", "request.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected receipt write error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedValidatePreviewRepairReceipts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"failed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"failed","slides":[{"path":"slides/01.svg","rendered":false}]}`)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":1,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0}}`)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed lint/preview receipts to block completion")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedQualityReport(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"failed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0}}`)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed quality report to block completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") && !strings.Contains(err.Error(), "status is \"failed\"") {
|
||||
t.Fatalf("error = %v, want quality report failure", err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func stageStatus(t *testing.T, run Run, name string) string {
|
||||
t.Helper()
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == name {
|
||||
return stage.Status
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return ""
|
||||
}
|
||||
382
internal/svglide/status.go
Normal file
382
internal/svglide/status.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type StatusReport struct {
|
||||
CurrentStage string `json:"current_stage"`
|
||||
MissingInputs []string `json:"missing_inputs"`
|
||||
MissingOutputs []string `json:"missing_outputs"`
|
||||
NextCommand string `json:"next_command"`
|
||||
}
|
||||
|
||||
type NextTaskReport struct {
|
||||
Stage string `json:"stage"`
|
||||
Mode string `json:"mode"`
|
||||
ApprovalRequired bool `json:"approval_required"`
|
||||
BlockingOwner string `json:"blocking_owner"`
|
||||
BlockingReason string `json:"blocking_reason,omitempty"`
|
||||
PromptPath string `json:"prompt_path,omitempty"`
|
||||
PromptPaths []string `json:"prompt_paths"`
|
||||
AdapterPaths []string `json:"adapter_paths"`
|
||||
PromptManifest string `json:"prompt_manifest"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
}
|
||||
|
||||
const (
|
||||
createSVGlideAdapterPath = "skills/lark-slides/references/lark-slides-create-svglide.md"
|
||||
svglideExecutionMode = "execution"
|
||||
svglideBlockingOwner = "svglide-runtime"
|
||||
)
|
||||
|
||||
func ReadRun(root string) (Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return readRunFile(safeRoot)
|
||||
}
|
||||
|
||||
func InspectStatus(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return StatusReport{
|
||||
CurrentStage: stage.Name,
|
||||
MissingInputs: missingInputs,
|
||||
MissingOutputs: missingOutputs,
|
||||
NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action next --run %s", shellQuote(root)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NextTask(root string) (NextTaskReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
if len(missingInputs) > 0 {
|
||||
return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", "))
|
||||
}
|
||||
inputs, err := validateRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
outputs, err := validateRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
return NextTaskReport{
|
||||
Stage: stage.Name,
|
||||
Mode: svglideExecutionMode,
|
||||
ApprovalRequired: false,
|
||||
BlockingOwner: svglideBlockingOwner,
|
||||
PromptPaths: PromptPathsForStage(stage.Name),
|
||||
AdapterPaths: []string{createSVGlideAdapterPath},
|
||||
PromptManifest: "prompt_manifest.json",
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readRun(root string) (string, Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
return safeRoot, run, nil
|
||||
}
|
||||
|
||||
func readRunFile(safeRoot string) (Run, error) {
|
||||
raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json"))
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
return Run{}, fmt.Errorf("read run.json: %w", err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func currentStage(run Run) (Stage, error) {
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return stage, nil
|
||||
}
|
||||
}
|
||||
return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func missingRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
var missing []string
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
exists, err := runGlobExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
continue
|
||||
}
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lstat run path %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
func validateRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
paths := make([]string, 0, len(rels))
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err := safeRunPath(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func runGlobExists(safeRoot, rel string) (bool, error) {
|
||||
dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
dirPath, exists, err := runDirectoryExists(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
entries, err := vfs.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read glob directory for %q: %w", rel, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := filepath.Match(pattern, entry.Name())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
matchRel := filepath.Join(dirRel, entry.Name())
|
||||
exists, err := runRegularFileExists(safeRoot, matchRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err)
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", "", "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
dirRel, pattern := filepath.Split(cleanRel)
|
||||
dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator))
|
||||
if pattern == "" {
|
||||
return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel)
|
||||
}
|
||||
if _, err := filepath.Match(pattern, ""); err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if dirRel == "" {
|
||||
dirRel = "."
|
||||
}
|
||||
if hasGlobMeta(dirRel) {
|
||||
return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel)
|
||||
}
|
||||
dirPath, err := safeRunPath(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return dirRel, pattern, dirPath, nil
|
||||
}
|
||||
|
||||
func runDirectoryExists(safeRoot, rel string) (string, bool, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return path, false, err
|
||||
}
|
||||
if !exists {
|
||||
return path, false, nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return path, false, fmt.Errorf("run path %q is not a directory", rel)
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func runRegularFileExists(safeRoot, rel string) (bool, error) {
|
||||
info, _, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
return info.Mode().IsRegular(), nil
|
||||
}
|
||||
|
||||
func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
var info fs.FileInfo
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
if info == nil {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
|
||||
func hasGlobMeta(path string) bool {
|
||||
return strings.ContainsAny(path, "*?[")
|
||||
}
|
||||
|
||||
func safeRunPath(safeRoot, rel string) (string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
path := filepath.Clean(filepath.Join(safeRoot, cleanRel))
|
||||
rootRel, err := filepath.Rel(safeRoot, path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err)
|
||||
}
|
||||
if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) {
|
||||
return "", fmt.Errorf("run path %q escapes run root", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isAbsoluteRunPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||
drive := path[0]
|
||||
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
if value == "" {
|
||||
return "''"
|
||||
}
|
||||
if isShellBareword(value) {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func isShellBareword(value string) bool {
|
||||
for _, r := range value {
|
||||
if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune("_@%+=:,./-", r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
497
internal/svglide/status_test.go
Normal file
497
internal/svglide/status_test.go
Normal file
@@ -0,0 +1,497 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatusReportsMissingOutputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs)
|
||||
}
|
||||
if len(status.MissingInputs) != 0 {
|
||||
t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs)
|
||||
}
|
||||
if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" {
|
||||
t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusQuotesNextCommandRunPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
root string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
root: "demo dir",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo dir'",
|
||||
},
|
||||
{
|
||||
root: "demo' dir",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo'\\'' dir'",
|
||||
},
|
||||
{
|
||||
root: "demo trail ",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo trail '",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.root, func(t *testing.T) {
|
||||
cwd := initStatusTestRunAt(t, tt.root)
|
||||
|
||||
status, err := InspectStatus(tt.root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.NextCommand != tt.want {
|
||||
t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want)
|
||||
}
|
||||
if strings.Contains(status.NextCommand, cwd) {
|
||||
t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskReturnsAnyGenPromptAssets(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if next.Stage != StageRequest {
|
||||
t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest)
|
||||
}
|
||||
if next.PromptManifest != "prompt_manifest.json" {
|
||||
t.Fatalf("PromptManifest = %q, want prompt_manifest.json", next.PromptManifest)
|
||||
}
|
||||
if next.PromptPath != "" {
|
||||
t.Fatalf("PromptPath = %q, want empty deprecated field", next.PromptPath)
|
||||
}
|
||||
got := strings.Join(next.PromptPaths, "\n")
|
||||
for _, want := range []string{
|
||||
"skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md",
|
||||
"skills/lark-slides/references/anygen-svg/svg_reference.md",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("PromptPaths missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if len(next.Inputs) != 0 {
|
||||
t.Fatalf("Inputs = %v, want empty", next.Inputs)
|
||||
}
|
||||
if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) {
|
||||
t.Fatalf("Outputs = %v, want request outputs", next.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskSeparatesAnyGenPromptsFromRuntimeAdapter(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
gotPrompts := strings.Join(next.PromptPaths, "\n")
|
||||
if strings.Contains(gotPrompts, "lark-slides-create-svglide.md") {
|
||||
t.Fatalf("PromptPaths should contain AnyGen assets only, got:\n%s", gotPrompts)
|
||||
}
|
||||
if !strings.Contains(gotPrompts, "skills/lark-slides/references/anygen-svg/README.md") {
|
||||
t.Fatalf("PromptPaths missing AnyGen README:\n%s", gotPrompts)
|
||||
}
|
||||
if len(next.AdapterPaths) != 1 || next.AdapterPaths[0] != "skills/lark-slides/references/lark-slides-create-svglide.md" {
|
||||
t.Fatalf("AdapterPaths = %#v, want create-svglide adapter", next.AdapterPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskDeclaresExecutionModeWithoutApprovalGate(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
if next.Mode != "execution" {
|
||||
t.Fatalf("Mode = %q, want execution", next.Mode)
|
||||
}
|
||||
if next.ApprovalRequired {
|
||||
t.Fatalf("ApprovalRequired = true, want false")
|
||||
}
|
||||
if next.BlockingOwner != "svglide-runtime" {
|
||||
t.Fatalf("BlockingOwner = %q, want svglide-runtime", next.BlockingOwner)
|
||||
}
|
||||
if next.BlockingReason != "" {
|
||||
t.Fatalf("BlockingReason = %q, want empty", next.BlockingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if _, err := InspectStatus("../escape"); err == nil {
|
||||
t.Fatal("expected unsafe run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
|
||||
run, err := ReadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
|
||||
if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil {
|
||||
t.Fatal("expected absolute run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected stat error when output parent is a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
path := filepath.Join("demo", "request", "source_manifest.json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageDesignBrief
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing current stage inputs to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing research source manifest to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageOutline
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing outline visual system to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/request.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides")
|
||||
if err := os.MkdirAll(outsideSlides, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside.svg")
|
||||
if err := os.WriteFile(outside, []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func initStatusTestRun(t *testing.T) string {
|
||||
return initStatusTestRunAt(t, "demo")
|
||||
}
|
||||
|
||||
func initStatusTestRunAt(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initRoot := root
|
||||
if trimmed := strings.TrimSpace(root); trimmed != root {
|
||||
initRoot = trimmed
|
||||
}
|
||||
if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if initRoot != root {
|
||||
if err := os.Rename(initRoot, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func readStatusTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeStatusTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) {
|
||||
t.Helper()
|
||||
for i := range run.Stages {
|
||||
if run.Stages[i].Name == stageName {
|
||||
run.Stages[i].Outputs = outputs
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", stageName)
|
||||
}
|
||||
|
||||
func setCurrentStageForStatusTest(t *testing.T, stageName string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = stageName
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
524
internal/svglide/validate.go
Normal file
524
internal/svglide/validate.go
Normal file
@@ -0,0 +1,524 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const slideNamespace = "https://slides.bytedance.com/ns"
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
const xlinkNamespace = "http://www.w3.org/1999/xlink"
|
||||
|
||||
type ValidationReport struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ValidationIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
type validationDeck struct {
|
||||
Slides []validationDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type validationDeckSlide struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type svgViewBox struct {
|
||||
Width float64
|
||||
Height float64
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type svgLintElement struct {
|
||||
Excluded bool
|
||||
TextCandidate bool
|
||||
}
|
||||
|
||||
func ValidateRun(root string) (ValidationReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ValidationReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return failValidation(safeRoot, ValidationIssue{Code: "svglide.deck", Message: "deck artifact path is empty"}, fmt.Errorf("deck artifact path is empty"))
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
var deck validationDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains no slides", deckPath)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := ValidationReport{Issues: []ValidationIssue{}}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath := strings.TrimSpace(slide.Path)
|
||||
if slidePath == "" {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Code: "svglide.path", Message: "slide path must not be empty"})
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Path: slidePath, Code: "svglide.path", Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
report.Issues = append(report.Issues, lintSVG(slidePath, raw)...)
|
||||
}
|
||||
report = normalizeValidationReport(report)
|
||||
|
||||
if err := writeValidationArtifacts(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func failValidation(safeRoot string, issue ValidationIssue, err error) (ValidationReport, error) {
|
||||
report := ValidationReport{Issues: []ValidationIssue{issue}}
|
||||
report = normalizeValidationReport(report)
|
||||
if writeErr := writeValidationArtifacts(safeRoot, report); writeErr != nil {
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%w; write validation artifacts: %v", err, writeErr)
|
||||
}
|
||||
return report, writeErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readRunRegularArtifact(safeRoot string, rel string) ([]byte, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("run path %q is missing or not a regular file inside run root", rel)
|
||||
}
|
||||
raw, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read run path %q: %w", rel, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func lintSVG(path string, raw []byte) []ValidationIssue {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var issues []ValidationIssue
|
||||
var rootSeen bool
|
||||
var rootIsSVG bool
|
||||
var hasSlideRole bool
|
||||
var hasViewBox bool
|
||||
var hasVisibleContent bool
|
||||
var viewBox svgViewBox
|
||||
var stack []svgLintElement
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: fmt.Sprintf("invalid XML: %v", err)}}
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded
|
||||
excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed)
|
||||
ctx := svgLintElement{
|
||||
Excluded: excluded,
|
||||
TextCandidate: elementIsTextCandidate(typed),
|
||||
}
|
||||
if !rootSeen {
|
||||
rootSeen = true
|
||||
rootIsSVG = typed.Name.Local == "svg" && typed.Name.Space == svgNamespace
|
||||
hasSlideRole = hasRootSlideRole(typed)
|
||||
viewBox, hasViewBox = rootViewBox(typed)
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
stack = append(stack, ctx)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
if elementCountsAsVisibleContent(typed, viewBox, excluded) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
stack = append(stack, ctx)
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(typed)) != "" && activeVisibleTextCandidate(stack) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !rootSeen {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: "invalid XML: missing root element"}}
|
||||
}
|
||||
if !rootIsSVG {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.root", Message: "root element must be <svg>"})
|
||||
}
|
||||
if !hasSlideRole {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.slide_role", Message: `root element must include slide:role="slide"`})
|
||||
}
|
||||
if !hasViewBox {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include viewBox"})
|
||||
} else if !viewBox.Valid {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include valid viewBox"})
|
||||
}
|
||||
if rootIsSVG && !hasVisibleContent {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.visible_content", Message: "slide contains only background/placeholder content"})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func hasRootSlideRole(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if strings.TrimSpace(attr.Value) != "slide" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Local == "role" && attr.Name.Space == slideNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rootViewBox(start xml.StartElement) (svgViewBox, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" || attr.Name.Local != "viewBox" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
return parseViewBox(attr.Value), true
|
||||
}
|
||||
return svgViewBox{}, false
|
||||
}
|
||||
|
||||
func parseViewBox(value string) svgViewBox {
|
||||
fields := strings.Fields(strings.ReplaceAll(value, ",", " "))
|
||||
if len(fields) != 4 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values := make([]float64, 4)
|
||||
for i, field := range fields {
|
||||
parsed, err := strconv.ParseFloat(field, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values[i] = parsed
|
||||
}
|
||||
width := values[2]
|
||||
height := values[3]
|
||||
if width <= 0 || height <= 0 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
return svgViewBox{Width: width, Height: height, Valid: true}
|
||||
}
|
||||
|
||||
func lintSVGElementProtocol(path string, start xml.StartElement, excluded bool) []ValidationIssue {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return nil
|
||||
}
|
||||
|
||||
var issues []ValidationIssue
|
||||
if excluded {
|
||||
return issues
|
||||
}
|
||||
if elementHasNonPositiveDimension(start) {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.geometry",
|
||||
Message: fmt.Sprintf("<%s> has non-positive width or height", start.Name.Local),
|
||||
})
|
||||
}
|
||||
if start.Name.Local == "image" {
|
||||
if !hasSlideAttr(start, "role", "image") {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.image_role",
|
||||
Message: `image must include slide:role="image"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func elementHasNonPositiveDimension(start xml.StartElement) bool {
|
||||
for _, name := range []string{"width", "height"} {
|
||||
value, ok := plainAttr(start, name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parsed, ok := parseSVGDimension(value)
|
||||
if ok && parsed <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSlideAttr(start xml.StartElement, local string, value string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == slideNamespace && attr.Name.Local == local && strings.TrimSpace(attr.Value) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func plainAttr(start xml.StartElement, local string) (string, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == local {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseSVGDimension(value string) (float64, bool) {
|
||||
s := strings.TrimSpace(value)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
for _, suffix := range []string{"vmax", "vmin", "rem", "px", "%", "em", "pt", "pc", "in", "cm", "mm", "qh", "q", "ex", "ch", "vw", "vh"} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
s = strings.TrimSpace(s[:len(s)-len(suffix)])
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func elementCountsAsVisibleContent(start xml.StartElement, viewBox svgViewBox, excluded bool) bool {
|
||||
if excluded {
|
||||
return false
|
||||
}
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "text", "tspan":
|
||||
return false
|
||||
case "foreignObject", "chart":
|
||||
return true
|
||||
case "image", "use":
|
||||
return elementHasHref(start)
|
||||
case "g":
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
case "path", "circle", "ellipse", "line", "polyline", "polygon":
|
||||
return true
|
||||
case "rect":
|
||||
return !isBackgroundRect(start, viewBox)
|
||||
default:
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
}
|
||||
}
|
||||
|
||||
func activeVisibleTextCandidate(stack []svgLintElement) bool {
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
if stack[i].Excluded {
|
||||
return false
|
||||
}
|
||||
if stack[i].TextCandidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func elementIsTextCandidate(start xml.StartElement) bool {
|
||||
return start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan")
|
||||
}
|
||||
|
||||
func elementIsHidden(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
switch attr.Name.Local {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(attr.Value) {
|
||||
return true
|
||||
}
|
||||
case "style":
|
||||
if styleHidesElement(attr.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func styleHidesElement(style string) bool {
|
||||
for _, declaration := range strings.Split(style, ";") {
|
||||
name, value, ok := strings.Cut(declaration, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func opacityIsZero(value string) bool {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return false
|
||||
}
|
||||
return floatEqual(parsed, 0)
|
||||
}
|
||||
|
||||
func elementIsNonRendering(start xml.StartElement) bool {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "defs", "symbol", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "marker", "metadata", "title", "desc", "style", "script":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elementHasHref(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local != "href" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Space == "" || attr.Name.Space == xlinkNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSemanticMarker(start xml.StartElement, terms ...string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(attr.Name.Local)
|
||||
if name != "role" && name != "class" && name != "id" && !strings.HasPrefix(name, "data-") {
|
||||
continue
|
||||
}
|
||||
value := strings.ToLower(attr.Value)
|
||||
for _, term := range terms {
|
||||
if strings.Contains(value, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBackgroundRect(start xml.StartElement, viewBox svgViewBox) bool {
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return true
|
||||
}
|
||||
width := attrValue(start, "width")
|
||||
height := attrValue(start, "height")
|
||||
if width == "100%" && height == "100%" {
|
||||
return true
|
||||
}
|
||||
if !viewBox.Valid {
|
||||
return false
|
||||
}
|
||||
x := attrFloatDefault(start, "x", 0)
|
||||
y := attrFloatDefault(start, "y", 0)
|
||||
w, okW := parseAttrFloat(width)
|
||||
h, okH := parseAttrFloat(height)
|
||||
if !okW || !okH {
|
||||
return false
|
||||
}
|
||||
return floatEqual(x, 0) && floatEqual(y, 0) && floatEqual(w, viewBox.Width) && floatEqual(h, viewBox.Height)
|
||||
}
|
||||
|
||||
func attrValue(start xml.StartElement, name string) string {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == name {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func attrFloatDefault(start xml.StartElement, name string, fallback float64) float64 {
|
||||
value := attrValue(start, name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, ok := parseAttrFloat(value)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseAttrFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func floatEqual(a float64, b float64) bool {
|
||||
return math.Abs(a-b) < 0.001
|
||||
}
|
||||
993
internal/svglide/validate_test.go
Normal file
993
internal/svglide/validate_test.go
Normal file
@@ -0,0 +1,993 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRunRejectsBackgroundOnlySVGAndWritesRepairArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), backgroundOnlySVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected background-only SVG issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") {
|
||||
t.Fatalf("Issues = %+v, want background/placeholder issue", report.Issues)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || len(receipt.Issues) == 0 {
|
||||
t.Fatalf("lint receipt = %+v, want failing issues", receipt)
|
||||
}
|
||||
var lintReceipt validationLintReceipt
|
||||
if err := json.Unmarshal(raw, &lintReceipt); err != nil {
|
||||
t.Fatalf("lint receipt is not schema-compatible JSON: %v", err)
|
||||
}
|
||||
if lintReceipt.Status != "failed" {
|
||||
t.Fatalf("lint receipt status = %q, want failed", lintReceipt.Status)
|
||||
}
|
||||
if lintReceipt.Issues[0].Code == "" || lintReceipt.Issues[0].Severity == "" {
|
||||
t.Fatalf("lint receipt issue = %+v, want code and severity", lintReceipt.Issues[0])
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), "slides/01.svg") {
|
||||
t.Fatalf("repair queue = %q, want slide path", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunPassesVisibleTextSVG(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("Issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(queue)) != "No repair needed." {
|
||||
t.Fatalf("repair queue = %q, want no repair text", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEscapingSlidePath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "../outside.svg")
|
||||
writeValidateTestFile(t, "outside.svg", visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with escaping slide path: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsSlideSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: "slides/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(filepath.Join(outside, "01.svg"), filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: "slides/link/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.deckPath)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "01.svg"), []byte(visibleTextSVG()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked slide path: %+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDeckSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: filepath.Join("demo", "outline", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(outside, "deck.json"), filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: filepath.Join("demo", "outline_link", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "outline_link/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "outline_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeMinimalDeckAt(t, filepath.Join(outside, "deck.json"), "slides/01.svg")
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked deck path %q: %+v", tt.deckPath, report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEmptyDeck(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo")
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, "no slides")
|
||||
}
|
||||
|
||||
func TestValidateRunWritesRepairArtifactsForDeckReadFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing deck",
|
||||
setup: func(t *testing.T) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
{
|
||||
name: "invalid deck json",
|
||||
setup: func(t *testing.T) {
|
||||
writeValidateTestFile(t, filepath.Join("demo", "outline", "deck.json"), `{`)
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
tt.setup(t)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReadsDeckFromRunArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/bad.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReportsInvalidXML(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg><text>broken`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "XML") && !validationIssuesContain(report.Issues, "xml") {
|
||||
t.Fatalf("Issues = %+v, want XML parse issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRequiresSVGRootSlideRoleAndViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non svg root",
|
||||
svg: `<html><body>not svg</body></html>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "wrong svg namespace",
|
||||
svg: `<svg xmlns="https://wrong.example/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "missing slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "missing viewBox",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text>hello</text></svg>`,
|
||||
want: `viewBox`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespaced slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:foo="https://wrong.example" foo:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "unbound slide prefix role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, tt.want) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsInvalidViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
}{
|
||||
{
|
||||
name: "bad viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox origin fields",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad bad 960 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nan viewBox width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 NaN 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "zero viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 0 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox with full page rect",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><rect width="960" height="540" fill="#fff"/></svg>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresNonVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "text in defs",
|
||||
body: `<defs><text>hidden template</text></defs>`,
|
||||
},
|
||||
{
|
||||
name: "display none text",
|
||||
body: `<text display="none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden text",
|
||||
body: `<text visibility="hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style display none text",
|
||||
body: `<text style="display:none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style visibility hidden text",
|
||||
body: `<text style="visibility:hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "opacity zero text",
|
||||
body: `<text opacity="0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style opacity zero text",
|
||||
body: `<text style="opacity:0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
body: `<text> </text>`,
|
||||
},
|
||||
{
|
||||
name: "image without href",
|
||||
body: `<image slide:role="image" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "use without href",
|
||||
body: `<use x="10" y="10"/>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsWrongNamespaceVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "wrong namespace path",
|
||||
body: `<bad:path xmlns:bad="https://wrong.example/svg" d="M10 10h20v20z"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace text",
|
||||
body: `<bad:text xmlns:bad="https://wrong.example/svg">hidden by namespace</bad:text>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace image href",
|
||||
body: `<image xmlns:bad="https://wrong.example/svg" bad:href="asset.png" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace viewBox",
|
||||
body: `<text>hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
viewBox := `viewBox="0 0 960 540"`
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
viewBox = `bad:viewBox="0 0 960 540" xmlns:bad="https://wrong.example/svg"`
|
||||
}
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" ` + viewBox + `>` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsNamespacedXLinkHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" xlink:href="assets/images/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsNegativeElementDimensions(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefCaseInsensitive(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="HTTPS://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsImageWithoutImageRole(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image href="assets/images/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want image role issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresGeometryAndImageRoleInsideExcludedContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "defs image",
|
||||
body: `<defs><image href="assets/images/defs.png" width="-4px" height="120"/></defs><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "pattern image",
|
||||
body: `<pattern id="p"><image href="assets/images/pattern.png" width="120" height="0%"/></pattern><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "mask image",
|
||||
body: `<mask id="m"><image href="assets/images/mask.png" width="auto" height="-4px"/></mask><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "display none image",
|
||||
body: `<g display="none"><image href="assets/images/hidden.png" width="-4px" height="120"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden image",
|
||||
body: `<g visibility="hidden"><image href="assets/images/hidden.png" width="120" height="-4px"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "marker image role",
|
||||
body: `<marker id="mk"><image href="assets/images/marker.png" width="120" height="80"/></marker><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, want no remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want no image role issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefWithXLink(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" xlink:href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
}{
|
||||
{name: "parent directory", href: "../outside.png"},
|
||||
{name: "absolute path", href: "/Users/example/secret.png"},
|
||||
{name: "file url", href: "file:///tmp/secret.png"},
|
||||
{name: "protocol relative", href: "//example.com/hero.png"},
|
||||
{name: "data url", href: "data:image/png;base64,AAAA"},
|
||||
{name: "percent encoding", href: "assets/images/hero%2epng"},
|
||||
{name: "nested asset path", href: "assets/images/nested/hero.png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="`+tt.href+`" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true for %s: %+v", tt.href, report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefInsideExcludedContent(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<defs><image href="file:///tmp/secret.png" width="-4px" height="120"/></defs>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue inside excluded content", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDimensionUnits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "negative px",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4px" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero percent",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="0%" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auto width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="auto" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Fine</div>
|
||||
</foreignObject>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.want {
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsPlainHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" href="assets/images/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected receipt symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "lint.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lint receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsLintReceiptFileSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "lint.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-lint.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected lint receipt file symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside file was overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initValidateTestRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitRun("demo", InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func writeMinimalDeck(t *testing.T, root string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
writeMinimalDeckAt(t, filepath.Join(root, "outline", "deck.json"), slidePaths...)
|
||||
}
|
||||
|
||||
func writeMinimalDeckAt(t *testing.T, path string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
slides := make([]map[string]string, 0, len(slidePaths))
|
||||
for i, path := range slidePaths {
|
||||
slides = append(slides, map[string]string{
|
||||
"id": "slide-" + string(rune('1'+i)),
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "content",
|
||||
"key_message": "Message",
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": slides,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
|
||||
func readValidateTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeValidateTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidateTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundOnlySVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/></svg>`
|
||||
}
|
||||
|
||||
func visibleTextSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func validationIssuesContain(issues []ValidationIssue, needle string) bool {
|
||||
for _, issue := range issues {
|
||||
if strings.Contains(issue.Path, needle) || strings.Contains(issue.Message, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validationIssuesContainCode(issues []ValidationIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertValidationFailureArtifacts(t *testing.T, root string, report ValidationReport, needle string) {
|
||||
t.Helper()
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected validation issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, needle) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, needle)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(root, "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || !validationIssuesContain(receipt.Issues, needle) {
|
||||
t.Fatalf("lint receipt = %+v, want failing issue containing %q", receipt, needle)
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join(root, "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), needle) {
|
||||
t.Fatalf("repair queue = %q, want %q", string(queue), needle)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 15 * time.Second
|
||||
fetchTimeout = 5 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
|
||||
return DefaultFS.OpenFile(name, flag, perm)
|
||||
}
|
||||
func CreateTemp(dir, pattern string) (*os.File, error) { return DefaultFS.CreateTemp(dir, pattern) }
|
||||
func Mkdir(path string, perm fs.FileMode) error { return DefaultFS.Mkdir(path, perm) }
|
||||
func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirAll(path, perm) }
|
||||
func MkdirTemp(dir, pattern string) (string, error) { return DefaultFS.MkdirTemp(dir, pattern) }
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
|
||||
@@ -25,6 +25,7 @@ type FS interface {
|
||||
CreateTemp(dir, pattern string) (*os.File, error)
|
||||
|
||||
// Directory/File management
|
||||
Mkdir(path string, perm fs.FileMode) error
|
||||
MkdirAll(path string, perm fs.FileMode) error
|
||||
MkdirTemp(dir, pattern string) (string, error)
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
|
||||
@@ -30,6 +30,7 @@ func (OsFs) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)
|
||||
func (OsFs) CreateTemp(dir, pattern string) (*os.File, error) { return os.CreateTemp(dir, pattern) }
|
||||
|
||||
// Directory/File management
|
||||
func (OsFs) Mkdir(path string, perm fs.FileMode) error { return os.Mkdir(path, perm) }
|
||||
func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
func (OsFs) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) }
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
|
||||
@@ -23,6 +23,15 @@ func TestOsFsBasicOperations(t *testing.T) {
|
||||
fs := OsFs{}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Mkdir
|
||||
one := filepath.Join(dir, "one")
|
||||
if err := fs.Mkdir(one, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
if err := Mkdir(filepath.Join(dir, "two"), 0o755); err != nil {
|
||||
t.Fatalf("package Mkdir: %v", err)
|
||||
}
|
||||
|
||||
// MkdirAll
|
||||
sub := filepath.Join(dir, "a", "b")
|
||||
if err := fs.MkdirAll(sub, 0o755); err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.64",
|
||||
"version": "1.0.63",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -927,6 +927,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
if s.LocalOnly {
|
||||
return runLocalShortcut(cmd, f, s, botOnly)
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -948,6 +952,23 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
return err
|
||||
}
|
||||
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runLocalShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
as := core.AsBot
|
||||
if asFlag, _ := cmd.Flags().GetString("as"); asFlag != "" && core.Identity(asFlag) != core.AsAuto {
|
||||
as = core.Identity(asFlag)
|
||||
}
|
||||
if err := f.CheckIdentity(as, s.AuthTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
config := &core.CliConfig{Brand: core.BrandFeishu}
|
||||
rctx := newLocalRuntimeContext(cmd, f, s, config, as, botOnly)
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runShortcutWithContext(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1031,6 +1052,21 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
func newLocalRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
|
||||
ctx := cmd.Context()
|
||||
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
|
||||
rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f}
|
||||
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and cannot call OpenAPI", s.Service, s.Command)
|
||||
})
|
||||
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and has no bot identity", s.Service, s.Command)
|
||||
})
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
@@ -1253,5 +1289,12 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
if s.LocalOnly {
|
||||
cmd.Flags().String("as", "", "identity type: "+strings.Join(s.AuthTypes, " | "))
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
return
|
||||
}
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type Shortcut struct {
|
||||
HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
LocalOnly bool // pure local command: no identity, config, scope, SDK, or OpenAPI bootstrap
|
||||
|
||||
// Business logic hooks.
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
|
||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -101,7 +101,7 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
var brand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
brand = cfg.Brand
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -18,30 +17,20 @@ import (
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
localOfficeTokenPrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping the
|
||||
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officeTokenPrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
@@ -146,8 +135,7 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
|
||||
var v *errs.ValidationError
|
||||
if errors.As(wrapped, &v) {
|
||||
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||
return "", nil, v.WithParam("--file")
|
||||
}
|
||||
return "", nil, wrapped
|
||||
|
||||
@@ -332,21 +332,11 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maxBatchOperations caps how many sub-operations a single +batch-update may
|
||||
// carry. Every translated op (with its own cells/properties payload) is held in
|
||||
// the out slice at once before the whole batch is marshaled, so an unbounded
|
||||
// operation count is the same unbounded-materialization hazard as the fan-out
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
}
|
||||
if len(rawOps) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps))
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
|
||||
@@ -1,59 +1,4 @@
|
||||
{
|
||||
"+formula-verify": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
|
||||
},
|
||||
{
|
||||
"name": "max-locations",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max locations / samples per error type; default 20.",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "exit-on-error",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -80,32 +25,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+revision-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -154,14 +73,6 @@
|
||||
"desc": "Initial column count (default 20, max 200)",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
|
||||
"default": "sheet"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -308,7 +219,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
|
||||
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -604,7 +515,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1158,7 +1069,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
|
||||
"desc": "Group nesting level to ungroup; default 1 (outermost)",
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
@@ -1800,13 +1711,6 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -2835,7 +2739,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2855,13 +2759,6 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -2988,7 +2885,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3068,7 +2965,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3112,7 +3009,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3230,7 +3127,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -4169,7 +4066,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
|
||||
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -4850,138 +4747,5 @@
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-list": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "end-version",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "history-version-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "History version to revert to (from +history-list)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "transaction-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Async revert transaction id (from +history-revert)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+changeset-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "start-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "required",
|
||||
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
|
||||
},
|
||||
{
|
||||
"name": "end-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
|
||||
"default": "-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +241,6 @@
|
||||
"description": "字体颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"description": "字体名称/字族(例如 \"Arial\"、\"微软雅黑\"、\"宋体\")",
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"description": "字体大小(单位:px/像素,例如 10、12、14)",
|
||||
"type": "number"
|
||||
@@ -6502,9 +6498,6 @@
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
@@ -6874,9 +6867,6 @@
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
|
||||
@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -38,10 +38,9 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
@@ -166,7 +165,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
@@ -190,15 +188,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+changeset-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
|
||||
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
|
||||
},
|
||||
},
|
||||
"+chart-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -206,7 +195,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
@@ -416,7 +405,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -437,7 +426,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -474,7 +463,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
||||
@@ -537,7 +526,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -643,45 +632,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+formula-verify": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
||||
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
|
||||
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
|
||||
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
|
||||
},
|
||||
},
|
||||
"+history-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert-status": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -784,14 +734,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+revision-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+rows-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -826,7 +768,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -881,7 +822,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -1000,7 +941,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
@@ -53,7 +54,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) {
|
||||
flagSchemasOnce.Do(func() {
|
||||
var idx flagSchemaIndex
|
||||
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
|
||||
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
|
||||
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
|
||||
return
|
||||
}
|
||||
if idx.Flags == nil {
|
||||
|
||||
@@ -243,7 +243,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,20 +251,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// already reported above). Apply to both `number` and `integer` types.
|
||||
if num, ok := value.(float64); ok {
|
||||
if schema.Minimum != nil && num < *schema.Minimum {
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
@@ -357,7 +357,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
if schema.AdditionalProperties.Strict {
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
|
||||
@@ -281,18 +281,18 @@ func (m mapFlagView) validateRawTypes() error {
|
||||
// parse time; reject here too to keep batch/standalone parity.
|
||||
f, isNum := val.(float64)
|
||||
if !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
if math.Trunc(f) != f {
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
|
||||
}
|
||||
case "float64":
|
||||
if _, isNum := val.(float64); !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
case "bool":
|
||||
if _, isBool := val.(bool); !isBool {
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
@@ -45,8 +44,7 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
|
||||
// classification and only adds the domain's flag param.
|
||||
func sheetsInputStatError(flag string, err error) error {
|
||||
wrapped := common.WrapInputStatErrorTyped(err)
|
||||
var v *errs.ValidationError
|
||||
if errors.As(wrapped, &v) {
|
||||
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||
return v.WithParam(sheetsFlagParam(flag))
|
||||
}
|
||||
return wrapped
|
||||
@@ -54,30 +52,21 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
localOfficeTokenPrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officeTokenPrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
@@ -451,7 +440,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
@@ -462,9 +451,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
|
||||
@@ -215,8 +215,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
if borderStyles != nil {
|
||||
prototype["border_styles"] = borderStyles
|
||||
}
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var totalCells int64
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -226,13 +225,6 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
@@ -307,7 +299,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
clearType := normalizeClearType(runtime.Str("scope"))
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -390,10 +382,13 @@ var DropdownDelete = common.Shortcut{
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
// validateDropdownRanges enforces the shared maxBatchRanges cap.
|
||||
if _, err := validateDropdownRanges(runtime); err != nil {
|
||||
ranges, err := validateDropdownRanges(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ranges) > 100 {
|
||||
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -437,8 +432,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
}
|
||||
prototype = map[string]interface{}{"data_validation": validation}
|
||||
}
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var totalCells int64
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -448,13 +442,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
@@ -474,25 +461,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
|
||||
// ─── helpers resurrected from B3 (used here + future skills) ──────────
|
||||
|
||||
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
|
||||
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
|
||||
// the number of ops materialized into one batch_update.
|
||||
const maxBatchRanges = 100
|
||||
|
||||
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
|
||||
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
|
||||
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
|
||||
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
|
||||
// summing past it. totalCells is int64 to stay overflow-safe.
|
||||
func checkBatchStampBudget(totalCells int64) error {
|
||||
if totalCells > maxStampMatrixCells {
|
||||
return sheetsValidationForFlag("ranges",
|
||||
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
|
||||
totalCells, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDropdownRanges parses --ranges, requires every entry to carry a
|
||||
// sheet prefix, and returns the parsed list.
|
||||
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
@@ -522,9 +490,6 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if len(out) > maxBatchRanges {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_changeset ─────────────────────────────────────────────
|
||||
//
|
||||
// +changeset-get wraps the get_changeset read tool: fetch the raw changeset
|
||||
// (the list of edit actions) between two CS revisions of a spreadsheet, so a
|
||||
// human or reviewing agent can verify whether an AI edit actually fulfilled
|
||||
// the user's request.
|
||||
//
|
||||
// - --start-revision is the "before" baseline (required, >= 1).
|
||||
// - --end-revision is optional; when omitted it defaults to the latest
|
||||
// revision, returning every changeset from start up to now.
|
||||
// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap
|
||||
// is enforced server-side (sheet-facade-agg maxChangesetRevGap).
|
||||
|
||||
const changesetMaxRevGap = 20
|
||||
|
||||
// ChangesetGet fetches the raw changesets between two spreadsheet versions.
|
||||
var ChangesetGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+changeset-get",
|
||||
Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+changeset-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := changesetRevisions(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := changesetInput(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := changesetInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.",
|
||||
"The version gap is capped at 20 revisions (end - start + 1 <= 20).",
|
||||
},
|
||||
}
|
||||
|
||||
// changesetRevisions reads and validates the start / end revision flags.
|
||||
// end <= 0 means "not provided" (default to latest, resolved server-side); a
|
||||
// provided end must be >= start and within the 20-revision gap.
|
||||
func changesetRevisions(runtime flagView) (start int, end int, err error) {
|
||||
start = runtime.Int("start-revision")
|
||||
end = runtime.Int("end-revision")
|
||||
if start < 1 {
|
||||
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
|
||||
}
|
||||
if end > 0 {
|
||||
if end < start {
|
||||
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
|
||||
}
|
||||
if end-start+1 > changesetMaxRevGap {
|
||||
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
|
||||
}
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// changesetInput builds the get_changeset tool input. end_revision is only
|
||||
// sent when explicitly provided; otherwise the server defaults to latest.
|
||||
func changesetInput(runtime flagView) (map[string]interface{}, error) {
|
||||
start, end, err := changesetRevisions(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"start_revision": start,
|
||||
}
|
||||
if end > 0 {
|
||||
input["end_revision"] = end
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
|
||||
// is only sent when explicitly provided, otherwise the server defaults to the
|
||||
// latest revision.
|
||||
func TestChangesetGet_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "start + end bounded range",
|
||||
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
|
||||
wantInput: map[string]interface{}{
|
||||
"start_revision": float64(120),
|
||||
"end_revision": float64(135),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start only → end omitted (server defaults to latest)",
|
||||
args: []string{"--url", testURL, "--start-revision", "120"},
|
||||
wantInput: map[string]interface{}{
|
||||
"start_revision": float64(120),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChangesetGet, tt.args)
|
||||
got := decodeToolInput(t, body, "get_changeset")
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangesetGet_Validation covers the client-side revision guards, which
|
||||
// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20).
|
||||
func TestChangesetGet_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "start-revision must be >= 1",
|
||||
args: []string{"--url", testURL, "--start-revision", "0"},
|
||||
wantSub: "start-revision must be >= 1",
|
||||
},
|
||||
{
|
||||
name: "end before start rejected",
|
||||
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
|
||||
wantSub: "end-revision",
|
||||
},
|
||||
{
|
||||
name: "gap over 20 rejected",
|
||||
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
|
||||
wantSub: "version gap exceeds limit",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), c.wantSub) {
|
||||
t.Errorf("expected %q; got=%s|%s|%v", c.wantSub, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_formula_verify ───────────────────────────────────────
|
||||
//
|
||||
// Wraps verify_formula (read): scan formulas + cell error states across one
|
||||
// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! /
|
||||
// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors)
|
||||
// into a recalc.py-shaped JSON status report. The contract is the single
|
||||
// AI self-check entry point for the R10 "write → verify zero-error"
|
||||
// invariant — see canonical-spec/references/lark_sheet_formula_verify/.
|
||||
|
||||
// FormulaVerify wraps verify_formula. Sheet selection is optional (both
|
||||
// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans
|
||||
// every visible sub-sheet's current_region.
|
||||
var FormulaVerify = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+formula-verify",
|
||||
Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+formula-verify"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFormulaVerifySheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFormulaVerifyLimits(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
if runtime.Bool("exit-on-error") {
|
||||
return formulaVerifyExitOnError(out)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the
|
||||
// two multi-value selectors: at most one of --sheet-id / --sheet-name may be
|
||||
// non-empty (passing both is the high-frequency reflex confusion when the
|
||||
// caller cargo-cults the single-sheet shortcut signature). Both empty is the
|
||||
// documented "scan every visible sub-sheet" path. Control-char checks reuse
|
||||
// requireSheetSelector's logic on each item.
|
||||
func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error {
|
||||
ids := nonEmptySliceItems(runtime.StrSlice("sheet-id"))
|
||||
names := nonEmptySliceItems(runtime.StrSlice("sheet-name"))
|
||||
if len(ids) > 0 && len(names) > 0 {
|
||||
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "mutually exclusive"),
|
||||
sheetsInvalidParam("sheet-name", "mutually exclusive"),
|
||||
)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := requireSheetSelector(id, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
if err := requireSheetSelector("", name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or
|
||||
// negative flag value can't silently degrade the scan (the server-side
|
||||
// default would otherwise mask the typo).
|
||||
func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 {
|
||||
return sheetsValidationForFlag("max-locations", "--max-locations must be > 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nonEmptySliceItems trims and drops blanks from a repeated-flag value so
|
||||
// `--sheet-id ""` doesn't masquerade as a real entry.
|
||||
func nonEmptySliceItems(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formulaVerifyInput builds the verify_formula tool input map from CLI flags.
|
||||
// excel_id is required; everything else is optional per the schema.
|
||||
func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
}
|
||||
if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 {
|
||||
input["sheet_ids"] = ids
|
||||
} else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 {
|
||||
// The verify_formula schema only declares sheet_ids; the facade
|
||||
// accepts sheet_names as a parallel optional field so name-based
|
||||
// selection works without forcing the caller to pre-resolve. Mirrors
|
||||
// how the other read shortcuts pack both fields via
|
||||
// sheetSelectorForToolInput.
|
||||
input["sheet_names"] = names
|
||||
}
|
||||
if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 {
|
||||
input["ranges"] = ranges
|
||||
}
|
||||
if runtime.Changed("max-locations") {
|
||||
input["max_locations_per_error"] = runtime.Int("max-locations")
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// formulaVerifyExitOnError converts a verify_formula status into a non-zero
|
||||
// CLI exit when the caller passed --exit-on-error. status="errors_found"
|
||||
// is the only failure mode for this flag: "partial" means truncated but the
|
||||
// scanned slice is clean, and "success" is obviously clean. A missing /
|
||||
// unknown status is treated as a typed internal error because the tool's
|
||||
// schema guarantees the field and we don't want a silent zero-exit.
|
||||
func formulaVerifyExitOnError(out interface{}) error {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"verify_formula: missing status field in tool output")
|
||||
}
|
||||
status, _ := m["status"].(string)
|
||||
switch status {
|
||||
case "success", "partial":
|
||||
return nil
|
||||
case "errors_found":
|
||||
total, _ := util.ToFloat64(m["total_errors"])
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"verify_formula: %d formula error(s) detected; resolve and re-run", int(total)).
|
||||
WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success")
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"verify_formula: unexpected status %q", status)
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the
|
||||
// common input combinations: no selector (workbook-wide scan), explicit
|
||||
// sheet_ids, explicit ranges, and the optional max_locations_per_error
|
||||
// field. The test exercises the One-OpenAPI body
|
||||
// directly so the schema field names stay locked to the canonical
|
||||
// tool-schemas.json verify_formula node.
|
||||
func TestFormulaVerify_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "no selector — workbook-wide scan defaults",
|
||||
args: []string{"--url", testURL},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sheet_ids multi via repeat",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_ids": []interface{}{testSheetID, testSheetID2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sheet_names multi via comma",
|
||||
args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_names": []interface{}{"Sheet1", "Sheet2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ranges + max_locations",
|
||||
args: []string{
|
||||
"--url", testURL,
|
||||
"--range", "A1:Z200",
|
||||
"--range", "AA1:AZ100",
|
||||
"--max-locations", "5",
|
||||
},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"ranges": []interface{}{"A1:Z200", "AA1:AZ100"},
|
||||
"max_locations_per_error": float64(5),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, FormulaVerify, tt.args)
|
||||
got := decodeToolInput(t, body, "verify_formula")
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits
|
||||
// invoke_read (read scope) and not invoke_write — a scope mismatch here would
|
||||
// surface as a 403 from the gateway.
|
||||
func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL})
|
||||
if len(calls) == 0 {
|
||||
t.Fatalf("dry-run produced no api calls")
|
||||
}
|
||||
call, _ := calls[0].(map[string]interface{})
|
||||
url, _ := call["url"].(string)
|
||||
if !strings.HasSuffix(url, "/tools/invoke_read") {
|
||||
t.Errorf("verify_formula must hit invoke_read; got url=%q", url)
|
||||
}
|
||||
if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want {
|
||||
t.Errorf("url = %q, want %q", url, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector"
|
||||
// rule on the two multi-value flags. Both empty is the documented
|
||||
// workbook-wide scan path, so we only reject the both-supplied case.
|
||||
func TestFormulaVerify_RejectsBothSelectors(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--sheet-name", "Sheet1",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "mutually exclusive")
|
||||
gotParams := map[string]bool{}
|
||||
for _, p := range ve.Params {
|
||||
gotParams[p.Name] = true
|
||||
}
|
||||
if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] {
|
||||
t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like
|
||||
// `--max-locations 0`, which would otherwise be silently swallowed by the
|
||||
// "explicit value but unset" comparison in the input builder.
|
||||
func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "max-locations=0",
|
||||
args: []string{"--url", testURL, "--max-locations", "0"},
|
||||
want: "--max-locations must be > 0",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run"))
|
||||
requireValidation(t, err, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error
|
||||
// contract: success/partial → no error; errors_found → typed validation
|
||||
// error with SubtypeFailedPrecondition; missing or unknown status →
|
||||
// typed internal error so a silent zero-exit can never happen.
|
||||
func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success returns no error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil {
|
||||
t.Fatalf("success path returned err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial returns no error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil {
|
||||
t.Fatalf("partial path returned err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors_found yields failed_precondition with count", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError(map[string]interface{}{
|
||||
"status": "errors_found",
|
||||
"total_errors": float64(7),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "7 formula error") {
|
||||
t.Errorf("message %q must surface the error count", ve.Message)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Errorf("hint must be set so AI agents know to re-run after fixes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError("oops")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history (BE-1: +history-list) ─────────────────────────
|
||||
//
|
||||
// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI
|
||||
// invoke_read endpoint. The tool returns a sheet's version history. The
|
||||
// facade-agg tool already performs the response transform (minor_histories
|
||||
// trim / id → history_version_id / 4-field projection / RFC3339 create_time),
|
||||
// so the CLI passes the tool output straight through and does NOT re-implement
|
||||
// the transform client-side.
|
||||
//
|
||||
// History is workbook-level (no sheet selector), mirroring +workbook-info:
|
||||
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
|
||||
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
|
||||
//
|
||||
// Flags are declared inline here rather than via flagsFor(): the generated
|
||||
// flag_defs_gen.go / data/flag-defs.json are synced from sheet-skill-spec
|
||||
// (BE-3) and must not be hand-edited, so this hand-written shortcut owns its
|
||||
// own flag set. The two locator flags match +workbook-info's shape exactly.
|
||||
|
||||
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
|
||||
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
|
||||
// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required.
|
||||
func historyLocatorFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."},
|
||||
{Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."},
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryList wraps the history_list tool: list a spreadsheet's history
|
||||
// versions. Each item carries history_version_id / create_time / action /
|
||||
// all_block_revision (projected server-side). An empty sheet yields an empty
|
||||
// list and exit 0.
|
||||
//
|
||||
// Backward pagination: --end-version (optional int) maps to the tool's
|
||||
// `end_version` parameter. Omit on the first call to fetch the latest page.
|
||||
// On subsequent pages pass the previous response's next_end_version as
|
||||
// --end-version. The tool returns next_end_version + has_more only when
|
||||
// more history exists; both fields are absent at the earliest page.
|
||||
var HistoryList = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-list",
|
||||
Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: append(historyLocatorFlags(),
|
||||
common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."},
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "history_list", historyListInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Pass the tool output through verbatim — facade-agg already shaped it.
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Capture a history_version_id from the result to feed +history-revert.",
|
||||
"For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).",
|
||||
},
|
||||
}
|
||||
|
||||
// historyListInput composes the history_list tool input. --end-version is
|
||||
// optional: include it only when explicitly set so the server treats absence
|
||||
// as "first page (latest)".
|
||||
func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
in := map[string]interface{}{"excel_id": token}
|
||||
if runtime.Changed("end-version") {
|
||||
in["end_version"] = runtime.Int("end-version")
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history (BE-2: +history-revert / +history-revert-status) ──
|
||||
//
|
||||
// Two thin callTool wrappers over the facade-agg history tools:
|
||||
// - +history-revert → history_revert (write) — async revert
|
||||
// - +history-revert-status → history_revert_status (read) — poll outcome
|
||||
//
|
||||
// Both target a single history version via --history-version-id (the id
|
||||
// surfaced by +history-list). Revert is asynchronous: it returns a receipt /
|
||||
// transaction id that +history-revert-status then polls, distinguishing
|
||||
// in-progress / success / failure from the tool output (passed through
|
||||
// verbatim — no client-side shaping).
|
||||
//
|
||||
// ⚠️ Backend state: the facade-agg history_revert / history_revert_status
|
||||
// tools are registered but their downstream RPC wiring is a DEFERRED
|
||||
// follow-up; today they return a "not wired yet" guard error from the gateway,
|
||||
// which surfaces here as a normal tool error. These CLI shortcuts are correct
|
||||
// thin wrappers and will work end-to-end once the backend follow-up lands —
|
||||
// this is NOT a CLI blocker. See self_check.md.
|
||||
//
|
||||
// Flags are declared inline (historyLocatorFlags + history-version-id) rather
|
||||
// than via flagsFor(), because flag_defs_gen.go / data/flag-defs.json are
|
||||
// synced from sheet-skill-spec (BE-3) and must not be hand-edited.
|
||||
|
||||
// historyVersionIDFlag is the target-version selector shared by +history-revert.
|
||||
// Required at the cli surface (cobra MarkFlagRequired): a missing value yields
|
||||
// cobra's standard "required flag(s) \"history-version-id\" not set" message
|
||||
// before Validate runs. We still trim + reject control-chars in Validate to
|
||||
// reject empty strings ("--history-version-id "" "), which cobra accepts.
|
||||
func historyVersionIDFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "history-version-id",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Desc: "History version to act on (from +history-list).",
|
||||
}
|
||||
}
|
||||
|
||||
func historyRevertFlags() []common.Flag {
|
||||
return append(historyLocatorFlags(), historyVersionIDFlag())
|
||||
}
|
||||
|
||||
// validateHistoryVersionID enforces the required, control-char-clean
|
||||
// --history-version-id. Returns the trimmed value so callers reuse it.
|
||||
func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(runtime.Str("history-version-id"))
|
||||
if id == "" {
|
||||
return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func historyRevertInput(token, versionID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"history_version_id": versionID,
|
||||
}
|
||||
}
|
||||
|
||||
// transactionIDFlag is the async-revert receipt selector used by
|
||||
// +history-revert-status: the transaction_id returned by +history-revert (NOT a
|
||||
// history version id — the facade-agg status tool keys on transaction_id).
|
||||
// Required at the cli surface (cobra MarkFlagRequired) — same gating model as
|
||||
// historyVersionIDFlag. Validate still trims + rejects empty/control-char
|
||||
// values to catch the case where cobra accepts --transaction-id with an
|
||||
// empty-string value.
|
||||
func transactionIDFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "transaction-id",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Desc: "Async revert transaction id (from +history-revert).",
|
||||
}
|
||||
}
|
||||
|
||||
func historyRevertStatusFlags() []common.Flag {
|
||||
return append(historyLocatorFlags(), transactionIDFlag())
|
||||
}
|
||||
|
||||
// validateTransactionID enforces the required, trimmed --transaction-id and
|
||||
// returns it for reuse.
|
||||
func validateTransactionID(runtime *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(runtime.Str("transaction-id"))
|
||||
if id == "" {
|
||||
return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func historyRevertStatusInput(token, transactionID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"transaction_id": transactionID,
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryRevert wraps the history_revert tool (write): asynchronously revert a
|
||||
// spreadsheet to the given history version. --history-version-id is required
|
||||
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
|
||||
// Validate runs with cobra's standard "required flag(s)" error (which the
|
||||
// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still
|
||||
// trim + reject empty / control-char values in Validate to catch the
|
||||
// case where cobra accepts --history-version-id with an empty-string value.
|
||||
var HistoryRevert = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: historyRevertFlags(),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateHistoryVersionID(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
versionID := strings.TrimSpace(runtime.Str("history-version-id"))
|
||||
return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionID, err := validateHistoryVersionID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.",
|
||||
},
|
||||
}
|
||||
|
||||
// HistoryRevertStatus wraps the history_revert_status tool (read): poll the
|
||||
// outcome of a prior +history-revert. The tool output distinguishes
|
||||
// in-progress / success / failure and is passed through verbatim.
|
||||
var HistoryRevertStatus = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Poll the status of a history revert (in-progress / success / failure).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: historyRevertStatusFlags(),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateTransactionID(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
txnID := strings.TrimSpace(runtime.Str("transaction-id"))
|
||||
return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txnID, err := validateTransactionID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right
|
||||
// facade-agg tool, routes through the correct read/write invoke endpoint, and
|
||||
// builds the expected tool input (excel_id always; history_version_id for the
|
||||
// revert pair).
|
||||
func TestHistoryShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const versionID = "histVER123"
|
||||
const txnID = "txn-abc-123"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantPath string // invoke_read | invoke_write suffix
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+history-list via --url",
|
||||
sc: HistoryList,
|
||||
args: []string{"--url", testURL},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-list via --spreadsheet-token",
|
||||
sc: HistoryList,
|
||||
args: []string{"--spreadsheet-token", testToken},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-list paginates with --end-version",
|
||||
sc: HistoryList,
|
||||
args: []string{"--url", testURL, "--end-version", "12345"},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"end_version": float64(12345), // post-JSON-unmarshal numeric type
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-revert routes to invoke_write with version id",
|
||||
sc: HistoryRevert,
|
||||
args: []string{"--url", testURL, "--history-version-id", versionID},
|
||||
toolName: "history_revert",
|
||||
wantPath: "invoke_write",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"history_version_id": versionID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-revert-status routes to invoke_read with transaction id",
|
||||
sc: HistoryRevertStatus,
|
||||
args: []string{"--url", testURL, "--transaction-id", txnID},
|
||||
toolName: "history_revert_status",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"transaction_id": txnID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
callURL := dryRunFirstCallURL(t, tt.sc, tt.args)
|
||||
if !containsSuffix(callURL, tt.wantPath) {
|
||||
t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath)
|
||||
}
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a
|
||||
// missing required selector before any request is sent, with two distinct
|
||||
// gates by design:
|
||||
//
|
||||
// - +history-revert: --history-version-id is cobra-required (Required=true
|
||||
// in the flag def → MarkFlagRequired). cobra refuses the call before
|
||||
// Validate runs with a plain "required flag(s)" error; the cmd dispatcher
|
||||
// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2).
|
||||
// The test rig invokes the shortcut via cmd.Execute and observes the raw
|
||||
// cobra error directly (no dispatcher wrap), so we assert the cobra text
|
||||
// contract instead of the typed envelope.
|
||||
//
|
||||
// - +history-revert-status: --transaction-id is cobra-optional;
|
||||
// requiredness is enforced inside Validate so we still get a typed,
|
||||
// flag-tagged *errs.ValidationError with Param="--transaction-id".
|
||||
func TestHistoryRevert_MissingRequiredFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(HistoryRevert.Command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") {
|
||||
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(HistoryRevertStatus.Command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") {
|
||||
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first
|
||||
// api call's url, so tests can assert read vs. write endpoint routing.
|
||||
func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string {
|
||||
t.Helper()
|
||||
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
||||
}
|
||||
dryRun := decodeDryRunRaw(t, out)
|
||||
calls, ok := dryRun["api"].([]interface{})
|
||||
if !ok || len(calls) == 0 {
|
||||
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
|
||||
}
|
||||
call, _ := calls[0].(map[string]interface{})
|
||||
url, _ := call["url"].(string)
|
||||
return url
|
||||
}
|
||||
|
||||
func containsSuffix(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_revision_get ───────────────────────────────────────────
|
||||
//
|
||||
// RevisionGet is a read-only derivative over get_workbook_structure that
|
||||
// projects out only the document revision (version number). The backend
|
||||
// surfaces `revision` on every read/write tool response, so this shortcut
|
||||
// needs no dedicated backend tool — it issues the lightest existing read
|
||||
// (no range, just the workbook token) and narrows the payload to the single
|
||||
// field callers want.
|
||||
//
|
||||
// The revision is the anchor for recover / undo. Callers that have just run a
|
||||
// write already have it in that write's response; +revision-get is the
|
||||
// explicit, zero-side-effect way to fetch the current value on its own.
|
||||
var RevisionGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+revision-get",
|
||||
Description: "Get the spreadsheet's current document revision (version number).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+revision-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rev, err := projectRevision(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"revision": rev}, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"The revision is the version anchor for recover / undo; every read and write tool response already carries it.",
|
||||
},
|
||||
}
|
||||
|
||||
// projectRevision narrows a get_workbook_structure response to its `revision`
|
||||
// field. An absent revision means the backend predates revision injection on
|
||||
// read responses; surface that as an explicit error rather than emitting a
|
||||
// silent null.
|
||||
func projectRevision(out interface{}) (interface{}, error) {
|
||||
obj, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"get_workbook_structure returned non-object output")
|
||||
}
|
||||
rev, ok := obj["revision"]
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"get_workbook_structure did not return a revision (backend may not support it yet)")
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRevisionGetProjectRevision(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("extracts revision from a workbook-structure object", func(t *testing.T) {
|
||||
out := map[string]interface{}{
|
||||
"revision": float64(60),
|
||||
"sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}},
|
||||
}
|
||||
got, err := projectRevision(out)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != float64(60) {
|
||||
t.Errorf("revision = %v, want 60", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors when revision is absent", func(t *testing.T) {
|
||||
out := map[string]interface{}{"sheets": []interface{}{}}
|
||||
if _, err := projectRevision(out); err == nil {
|
||||
t.Error("expected an error when revision is missing, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors on a non-object output", func(t *testing.T) {
|
||||
if _, err := projectRevision("not-an-object"); err == nil {
|
||||
t.Error("expected an error for non-object output, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[
|
||||
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("range is empty")
|
||||
}
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) > 2 {
|
||||
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
|
||||
}
|
||||
dim1, idx1, err := parseA1Position(parts[0])
|
||||
if err != nil {
|
||||
@@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if dim1 != dim2 {
|
||||
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
|
||||
}
|
||||
if idx2 < idx1 {
|
||||
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("end position is before start")
|
||||
}
|
||||
return dim1, idx1, idx2, nil
|
||||
}
|
||||
@@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
|
||||
func parseA1Position(s string) (dimension string, idx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("position is empty")
|
||||
}
|
||||
isDigits := true
|
||||
isLetters := true
|
||||
@@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) {
|
||||
if isDigits {
|
||||
n, _ := strconv.Atoi(s)
|
||||
if n <= 0 {
|
||||
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
|
||||
}
|
||||
return "row", n - 1, nil
|
||||
}
|
||||
if isLetters {
|
||||
return "column", letterToColumnIndex(s), nil
|
||||
}
|
||||
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
|
||||
}
|
||||
|
||||
// columnIndexToLetter converts a 0-based column index to the spreadsheet
|
||||
|
||||
@@ -382,32 +382,6 @@ func (p *tablePayload) validate() error {
|
||||
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
|
||||
}
|
||||
}
|
||||
return p.checkCellBudget()
|
||||
}
|
||||
|
||||
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
|
||||
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
|
||||
// these cells come from the caller's own --sheets/--values payload rather than a
|
||||
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
|
||||
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
|
||||
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
|
||||
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
|
||||
// process before the first write leaves.
|
||||
const maxTablePutCells = 1_000_000
|
||||
|
||||
// checkCellBudget rejects a payload whose total materialized cell count across
|
||||
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
|
||||
// pathological row/column counts.
|
||||
func (p *tablePayload) checkCellBudget() error {
|
||||
var total int64
|
||||
for i := range p.Sheets {
|
||||
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
|
||||
}
|
||||
if total > maxTablePutCells {
|
||||
return common.ValidationErrorf(
|
||||
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
|
||||
total, maxTablePutCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -669,8 +643,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matrix, err = applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
if err != nil {
|
||||
if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -682,15 +655,11 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// styles can pad the matrix wider than the declared columns (cell_styles on
|
||||
// blank cells past the data extent), so the written width comes from the
|
||||
// padded matrix, not ncols.
|
||||
writeCols := len(matrix[0])
|
||||
startCol := columnIndexToLetter(col0)
|
||||
endCol := columnIndexToLetter(col0 + writeCols - 1)
|
||||
endCol := columnIndexToLetter(col0 + ncols - 1)
|
||||
allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite
|
||||
|
||||
rowsPerBatch := tablePutMaxCellsPerWrite / writeCols
|
||||
rowsPerBatch := tablePutMaxCellsPerWrite / ncols
|
||||
if rowsPerBatch < 1 {
|
||||
rowsPerBatch = 1
|
||||
}
|
||||
@@ -724,7 +693,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
"sheet_id": sheetID,
|
||||
"range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)),
|
||||
"data_rows": len(s.Rows),
|
||||
"columns": writeCols,
|
||||
"columns": ncols,
|
||||
"writes": writes,
|
||||
"mode": writeModeName(s),
|
||||
}, nil
|
||||
@@ -816,7 +785,7 @@ func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
s := &payload.Sheets[i]
|
||||
sheetID, ok := byName[s.Name]
|
||||
if !ok {
|
||||
rows, cols := sheetCreateDims(s, styles.styleFor(i))
|
||||
rows, cols := sheetCreateDims(s)
|
||||
sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols)
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||||
@@ -895,12 +864,10 @@ func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, nam
|
||||
|
||||
// sheetCreateDims sizes a to-be-created sheet to the spec's write range so the
|
||||
// follow-up set_cell_range can't exceed sheet bounds. It accounts for the
|
||||
// start_cell offset, the optional header row, and any --styles extent (so a
|
||||
// cell_styles / merge / resize op past the data still fits the grid). The
|
||||
// backend's 20×200 defaults are kept as floors (ordinary small tables are
|
||||
// created exactly as before) and its hard limits (200 cols, 50000 rows) as
|
||||
// ceilings.
|
||||
func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (rows, cols int) {
|
||||
// start_cell offset and the optional header row. The backend's 20×200 defaults
|
||||
// are kept as floors (ordinary small tables are created exactly as before) and
|
||||
// its hard limits (200 cols, 50000 rows) as ceilings.
|
||||
func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
cols = col0 + len(s.Columns)
|
||||
rows = row0 + len(s.Rows)
|
||||
@@ -915,19 +882,6 @@ func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (row
|
||||
if headerOn(s) || (s.Mode == "append" && s.Header == nil) {
|
||||
rows++
|
||||
}
|
||||
// --styles can reach past the data (cell_styles on blank cells get padded
|
||||
// into the matrix and written; merges / resizes run as separate ops). Size
|
||||
// the grid to cover them too. workbookCreateStyleDimensions returns the
|
||||
// extent relative to the anchor, so add the anchor offset back.
|
||||
if styles != nil {
|
||||
styleRows, styleCols := workbookCreateStyleDimensions(styles, col0, row0)
|
||||
if col0+styleCols > cols {
|
||||
cols = col0 + styleCols
|
||||
}
|
||||
if row0+styleRows > rows {
|
||||
rows = row0 + styleRows
|
||||
}
|
||||
}
|
||||
if cols < 20 {
|
||||
cols = 20
|
||||
}
|
||||
@@ -1025,6 +979,8 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
for i := range payload.Sheets {
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||||
s.Name, len(s.Rows), len(s.Columns), writeModeName(s))
|
||||
rng := tablePutFullRange(s, len(matrix))
|
||||
if s.Mode == "append" {
|
||||
rng = "<append below existing data>"
|
||||
@@ -1032,23 +988,10 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// cell_styles are merged into the matrix only for overwrite mode,
|
||||
// where the anchor row is known statically; append's base row is
|
||||
// resolved at execute time, so the preview leaves the matrix bare
|
||||
// (the merges / sizes ops below still render). Padding can widen /
|
||||
// lengthen the matrix past the data, so recompute the range from the
|
||||
// padded dims to match what Execute writes.
|
||||
// (the merges / sizes ops below still render).
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
if len(matrix) > 0 {
|
||||
rng = fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(col0), row0+1,
|
||||
columnIndexToLetter(col0+len(matrix[0])-1), row0+len(matrix))
|
||||
}
|
||||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
}
|
||||
writeCols := len(s.Columns)
|
||||
if len(matrix) > 0 {
|
||||
writeCols = len(matrix[0])
|
||||
}
|
||||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||||
s.Name, len(s.Rows), writeCols, writeModeName(s))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": s.Name,
|
||||
|
||||
@@ -537,15 +537,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec tableSheetSpec
|
||||
styles *workbookCreateStylePayload
|
||||
wantRows, wantCols int
|
||||
}{
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, nil, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, nil, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, nil, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, nil, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, nil, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, nil, 200, 200},
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
|
||||
// Default headerOn() is false for append mode, but writeSheetData forces
|
||||
// a header when append hits an empty sheet with no explicit Header
|
||||
// choice (so column names aren't lost). sheetCreateDims runs only on
|
||||
@@ -553,22 +552,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
// match: append + Header=nil ⇒ +1 row. Otherwise an append-near-50000
|
||||
// payload would be created one row short.
|
||||
{"append on new sheet sizes for the forced header row (49999 data rows + 1 header = 50000)",
|
||||
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, nil, 50000, 20},
|
||||
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, 50000, 20},
|
||||
{"append + Header=false (explicit) does NOT add the forced header row",
|
||||
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, nil, 200, 20},
|
||||
// --styles reaching past the data grows the grid so a cell_styles op on a
|
||||
// blank cell (or a merge / resize) still fits after the create.
|
||||
{"styles past the data grow the grid",
|
||||
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
|
||||
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z400"}}}, 400, 26},
|
||||
{"styles inside the data don't shrink the grid",
|
||||
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
|
||||
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:B2"}}}, 200, 20},
|
||||
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, 200, 20},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec, tt.styles)
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec)
|
||||
if gotRows != tt.wantRows || gotCols != tt.wantCols {
|
||||
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
|
||||
}
|
||||
|
||||
@@ -123,26 +123,6 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return nil, common.ValidationErrorf("--title is required")
|
||||
}
|
||||
// --type bitable 建一张空白多维表格子表(operation=create_bitable);默认 sheet 为普通
|
||||
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令,row-count/col-count 不适用。
|
||||
sheetType := strings.TrimSpace(runtime.Str("type"))
|
||||
if sheetType == "" {
|
||||
sheetType = "sheet"
|
||||
}
|
||||
if sheetType != "sheet" && sheetType != "bitable" {
|
||||
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
|
||||
}
|
||||
if sheetType == "bitable" {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "create_bitable",
|
||||
"sheet_name": strings.TrimSpace(runtime.Str("title")),
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
input["target_index"] = runtime.Int("index")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
|
||||
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
|
||||
}
|
||||
@@ -654,26 +634,16 @@ var WorkbookCreate = common.Shortcut{
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
// Padding can widen / lengthen the matrix past the data, so build the
|
||||
// range from the padded dims to match what Execute writes.
|
||||
rng := tablePutFullRange(s, len(matrix))
|
||||
writeCols := len(s.Columns)
|
||||
if len(matrix) > 0 {
|
||||
writeCols = len(matrix[0])
|
||||
rng = fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(col0), row0+1,
|
||||
columnIndexToLetter(col0+writeCols-1), row0+len(matrix))
|
||||
}
|
||||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": "<new-token>",
|
||||
"sheet_name": s.Name,
|
||||
"range": rng,
|
||||
"range": tablePutFullRange(s, len(matrix)),
|
||||
"cells": matrix,
|
||||
}
|
||||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), writeCols)).
|
||||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
|
||||
Body(wireBody)
|
||||
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", s.Name, sheetStyles.styleFor(i))
|
||||
}
|
||||
@@ -866,19 +836,13 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
|
||||
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
|
||||
}
|
||||
noHeader := false
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
return &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Name: valuesSheetName,
|
||||
Mode: "overwrite",
|
||||
Header: &noHeader,
|
||||
Columns: cols,
|
||||
Rows: rows,
|
||||
}}}
|
||||
// --values bypasses tablePayload.validate(), so enforce the cell budget here
|
||||
// too — otherwise a giant --values array materializes unbounded.
|
||||
if err := payload.checkCellBudget(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
|
||||
@@ -1282,7 +1246,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
case "font_color", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap":
|
||||
return true
|
||||
@@ -1393,79 +1357,20 @@ func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol,
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
// padMatrixForStyles grows the matrix down and right so it covers every
|
||||
// cell_styles range, appending empty cells for the positions the data doesn't
|
||||
// reach. cell_styles are applied by writing into the matrix in place (see
|
||||
// applyWorkbookCreateStylesToMatrix), so a style on a cell past the data extent
|
||||
// needs a real — if empty — cell to attach to. This lets a create/write set
|
||||
// styles on blank cells (reserved regions, decorative headers, empty borders).
|
||||
//
|
||||
// Only cell_styles contribute to the extent: cell_merges / row_sizes / col_sizes
|
||||
// run as separate API calls (see workbookCreateVisualOps) and never touch the
|
||||
// matrix. Ranges that start left of baseCol or above baseRow are skipped here
|
||||
// (the matrix can only extend down/right) and left for the caller to reject.
|
||||
// The (possibly reallocated) matrix is returned; callers must use the result.
|
||||
func padMatrixForStyles(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int) [][]interface{} {
|
||||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error {
|
||||
if styles == nil {
|
||||
return rows
|
||||
return nil
|
||||
}
|
||||
needRows := len(rows)
|
||||
needCols := 0
|
||||
if len(rows) > 0 {
|
||||
needCols = len(rows[0])
|
||||
}
|
||||
for _, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil || startCol < baseCol || startRow < baseRow {
|
||||
continue // unparsable, or up/left of the anchor: not paddable
|
||||
}
|
||||
if endCol-baseCol+1 > needCols {
|
||||
needCols = endCol - baseCol + 1
|
||||
}
|
||||
if endRow-baseRow+1 > needRows {
|
||||
needRows = endRow - baseRow + 1
|
||||
}
|
||||
}
|
||||
// Widen existing rows to needCols.
|
||||
for r := range rows {
|
||||
for len(rows[r]) < needCols {
|
||||
rows[r] = append(rows[r], map[string]interface{}{})
|
||||
}
|
||||
}
|
||||
// Append full empty rows to reach needRows.
|
||||
for len(rows) < needRows {
|
||||
row := make([]interface{}, needCols)
|
||||
for c := range row {
|
||||
row[c] = map[string]interface{}{}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// applyWorkbookCreateStylesToMatrix pads the matrix to cover the cell_styles
|
||||
// ranges (see padMatrixForStyles), merges each op's style into the covered
|
||||
// cells, and returns the padded matrix. A range that starts left of / above the
|
||||
// write anchor can't be padded to and is rejected.
|
||||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) ([][]interface{}, error) {
|
||||
if styles == nil {
|
||||
return rows, nil
|
||||
}
|
||||
rows = padMatrixForStyles(rows, styles, baseCol, baseRow)
|
||||
for i, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil {
|
||||
return rows, common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||||
return common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||||
}
|
||||
// After padding, the matrix reaches every range that starts at or after
|
||||
// the anchor; a start left of / above it can't be covered. The endRow /
|
||||
// endCol checks stay as a defensive backstop (padding should have made
|
||||
// them unreachable).
|
||||
if startCol < baseCol || startRow < baseRow || len(rows) == 0 ||
|
||||
endRow-baseRow >= len(rows) || endCol-baseCol >= len(rows[0]) {
|
||||
return rows, common.ValidationErrorf("%s[%d].range %q starts outside the write range (its top-left must be at or after %s%d)",
|
||||
if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) {
|
||||
return common.ValidationErrorf("%s[%d].range %q is outside the write range %s%d:%s%d",
|
||||
label, i, op.Range,
|
||||
columnIndexToLetter(baseCol), baseRow+1)
|
||||
columnIndexToLetter(baseCol), baseRow+1,
|
||||
columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows))
|
||||
}
|
||||
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
|
||||
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
|
||||
@@ -1473,7 +1378,7 @@ func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCre
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) {
|
||||
|
||||
@@ -396,37 +396,6 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style past the data pads empty cells so blank cells can be styled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Data is a single cell (A1), but the style targets A1:C3 — the matrix is
|
||||
// padded to 3x3 with empty cells so the style range fits, letting blank
|
||||
// cells carry styling. The written range must reflect the padded extent.
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:C3","background_color":"#FFEEAA"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:C3" {
|
||||
t.Errorf("range = %v, want A1:C3 (padded to the style extent)", input["range"])
|
||||
}
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Fatalf("cells rows = %d, want 3 (padded); cells=%#v", len(cells), input["cells"])
|
||||
}
|
||||
lastRow, _ := cells[2].([]interface{})
|
||||
if len(lastRow) != 3 {
|
||||
t.Fatalf("last row width = %d, want 3 (padded)", len(lastRow))
|
||||
}
|
||||
// A blank padded cell (C3) still carries the style.
|
||||
c3, _ := lastRow[2].(map[string]interface{})
|
||||
c3s, _ := c3["cell_styles"].(map[string]interface{})
|
||||
if c3s["background_color"] != "#FFEEAA" {
|
||||
t.Errorf("padded blank cell C3 style = %#v, want background_color", c3)
|
||||
}
|
||||
})
|
||||
t.Run("style-only payload (cell_merges) still fills and emits merge_cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Previously workbookCreateStyleDimensions only counted cell_styles, so a
|
||||
@@ -626,60 +595,3 @@ func deepEqualJSON(a, b interface{}) bool {
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
// TestApplyWorkbookCreateStylesToMatrix covers the pad-then-style behavior
|
||||
// directly: a style range past the data grows the matrix with empty cells (so
|
||||
// blank cells can be styled), an in-range style leaves the matrix size alone,
|
||||
// and a range up/left of the anchor — which padding can't reach — is rejected.
|
||||
func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell := func() interface{} { return map[string]interface{}{} }
|
||||
|
||||
t.Run("pads down and right for a style past the data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell()}} // 1x1 data
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1:C3", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"background_color": "#FFEEAA"}}},
|
||||
}}
|
||||
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) != 3 || len(out[0]) != 3 || len(out[2]) != 3 {
|
||||
t.Fatalf("padded matrix = %d rows x %v cols, want 3x3", len(out), out)
|
||||
}
|
||||
// A blank padded corner (C3) carries the style.
|
||||
c3, _ := out[2][2].(map[string]interface{})
|
||||
c3s, _ := c3["cell_styles"].(map[string]interface{})
|
||||
if c3s["background_color"] != "#FFEEAA" {
|
||||
t.Errorf("padded cell C3 = %#v, want background_color", c3)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no pad when the style is within the data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell(), cell()}, {cell(), cell()}} // 2x2 data
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1:B2", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
|
||||
}}
|
||||
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) != 2 || len(out[0]) != 2 {
|
||||
t.Errorf("matrix = %dx%d, want 2x2 (no pad)", len(out), len(out[0]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a range up/left of the anchor", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell()}} // anchored at C3 (col 2, row 2)
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
|
||||
}}
|
||||
_, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 2, 2, "--styles")
|
||||
if err == nil || !strings.Contains(err.Error(), "starts outside the write range") {
|
||||
t.Errorf("err = %v, want 'starts outside the write range'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
|
||||
// CellsSetStyle stamps a single style block across every cell in --range.
|
||||
// Style is composed from a dozen flat flags (background-color, font-color,
|
||||
// font-family, font-size, font-style, font-weight, font-line,
|
||||
// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus
|
||||
// --border-styles for the only field that still needs a nested object. At
|
||||
// least one flag must be set.
|
||||
// font-size, font-style, font-weight, font-line, horizontal-alignment,
|
||||
// vertical-alignment, word-wrap, number-format) plus --border-styles for
|
||||
// the only field that still needs a nested object. At least one flag must
|
||||
// be set.
|
||||
var CellsSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set-style",
|
||||
@@ -165,9 +165,6 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -453,9 +450,6 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation, err := buildDropdownValidation(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -631,23 +625,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||||
}
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("empty range")
|
||||
}
|
||||
parts := strings.SplitN(rangeStr, ":", 2)
|
||||
if len(parts) == 1 {
|
||||
// single cell, e.g. "A1"
|
||||
if _, _, ok := splitCellRef(parts[0]); !ok {
|
||||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
startCol, startRow, ok1 := splitCellRef(parts[0])
|
||||
endCol, endRow, ok2 := splitCellRef(parts[1])
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
|
||||
}
|
||||
if endRow < startRow || endCol < startCol {
|
||||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
|
||||
}
|
||||
return endRow - startRow + 1, endCol - startCol + 1, nil
|
||||
}
|
||||
@@ -698,30 +692,9 @@ func letterToColumnIndex(letters string) int {
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
|
||||
// will materialize from a single A1 range. The backing tools take an explicit
|
||||
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
|
||||
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
|
||||
// doubled again by json.Marshal) that OOMs the process before the request even
|
||||
// leaves. 200000 matches the documented --max-cells safety cap.
|
||||
const maxStampMatrixCells = 200000
|
||||
|
||||
// checkStampMatrixBudget rejects a range whose materialized cell count would
|
||||
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
|
||||
// computed in int64 to stay safe against overflow on pathological ranges.
|
||||
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
|
||||
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
|
||||
return sheetsValidationForFlag(flagName,
|
||||
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
|
||||
rangeStr, total, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
|
||||
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
|
||||
// single attribute (style / data_validation) across an entire range.
|
||||
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
|
||||
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
|
||||
cells := make([][]interface{}, rows)
|
||||
for r := range cells {
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
|
||||
// synthetic token and must upload with "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -36,12 +36,9 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
}{
|
||||
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
|
||||
{"empty token", "", sheetImageParentType},
|
||||
{"fake_office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"fake_office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
|
||||
{"local_office token, only the prefix", localOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
|
||||
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -65,8 +62,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
wantParentType string
|
||||
}{
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
|
||||
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These benchmarks back the memory review of the sheets fan-out / download
|
||||
// paths. They measure two hot spots:
|
||||
//
|
||||
// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set,
|
||||
// +cells-batch-set-style, +dropdown-update) expand one A1 range into a
|
||||
// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000")
|
||||
// explodes into millions of heap maps with no upper bound.
|
||||
//
|
||||
// 2. the export-download reader — strings.NewReader(string(rawBody)) copies
|
||||
// the whole downloaded file once more before saving it.
|
||||
//
|
||||
// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|DownloadReader' -benchmem
|
||||
|
||||
var styleProto = map[string]interface{}{
|
||||
"cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"},
|
||||
"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}},
|
||||
}
|
||||
|
||||
func benchFillCellsMatrix(b *testing.B, rows, cols int) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m := fillCellsMatrix(rows, cols, styleProto)
|
||||
if len(m) != rows {
|
||||
b.Fatalf("bad matrix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10
|
||||
func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000
|
||||
func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000
|
||||
func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000
|
||||
|
||||
// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of
|
||||
// materializing a large fan-out matrix, so the review doc can quote real MB.
|
||||
// Not an assertion — it prints numbers under `go test -v -run PeakMemory`.
|
||||
func TestFanoutMatrixPeakMemory(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping memory probe in -short")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rows, cols int
|
||||
}{
|
||||
{"A1:Z10000 (260K cells)", 10000, 26},
|
||||
{"A1:Z100000 (2.6M cells)", 100000, 26},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
m := fillCellsMatrix(c.rows, c.cols, styleProto)
|
||||
runtime.ReadMemStats(&after)
|
||||
runtime.KeepAlive(m)
|
||||
t.Logf("%-26s heap +%6.1f MB (%d total allocs)",
|
||||
c.name,
|
||||
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
|
||||
after.Mallocs-before.Mallocs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- +table-put / +workbook-create matrix materialization (sibling #1 path) ---
|
||||
//
|
||||
// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix
|
||||
// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the
|
||||
// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this
|
||||
// in-memory build). checkCellBudget rejects oversized payloads before this runs.
|
||||
|
||||
func makeTypelessSpec(rows, cols int) *tableSheetSpec {
|
||||
c := make([]tableColumnSpec, cols)
|
||||
r := make([][]interface{}, rows)
|
||||
for i := range r {
|
||||
row := make([]interface{}, cols)
|
||||
for j := range row {
|
||||
row[j] = "x"
|
||||
}
|
||||
r[i] = row
|
||||
}
|
||||
return &tableSheetSpec{Columns: c, Rows: r}
|
||||
}
|
||||
|
||||
func benchBuildSheetMatrix(b *testing.B, rows, cols int) {
|
||||
spec := makeTypelessSpec(rows, cols)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m, err := buildSheetMatrix(spec, true)
|
||||
if err != nil || len(m) != rows+1 {
|
||||
b.Fatalf("bad matrix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells
|
||||
func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells
|
||||
|
||||
// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing
|
||||
// a large table-put matrix (the cost checkCellBudget now prevents), so the
|
||||
// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory.
|
||||
func TestTablePutMatrixPeakMemory(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping memory probe in -short")
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
rows, cols int
|
||||
}{
|
||||
{"100000×26 (2.6M cells)", 100000, 26},
|
||||
} {
|
||||
spec := makeTypelessSpec(c.rows, c.cols)
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
m, _ := buildSheetMatrix(spec, true)
|
||||
runtime.ReadMemStats(&after)
|
||||
runtime.KeepAlive(m)
|
||||
t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)",
|
||||
c.name,
|
||||
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
|
||||
after.Mallocs-before.Mallocs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- export-download reader copy ---
|
||||
|
||||
func benchDownloadReader(b *testing.B, size int, useStringCopy bool) {
|
||||
raw := bytes.Repeat([]byte("x"), size)
|
||||
sink := make([]byte, 32*1024)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var r io.Reader
|
||||
if useStringCopy {
|
||||
r = strings.NewReader(string(raw)) // current code: extra full-size copy
|
||||
} else {
|
||||
r = bytes.NewReader(raw) // fix: no copy
|
||||
}
|
||||
for {
|
||||
if _, err := r.Read(sink); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) ---
|
||||
|
||||
func TestStampMatrixBudgetCap(t *testing.T) {
|
||||
// 199992 cells (7692×26) sits just under the 200000 cap → allowed.
|
||||
if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil {
|
||||
t.Fatalf("199992 cells should pass, got: %v", err)
|
||||
}
|
||||
// Exactly at the cap → allowed.
|
||||
if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil {
|
||||
t.Fatalf("200000 cells (== cap) should pass, got: %v", err)
|
||||
}
|
||||
// Just over the cap → rejected.
|
||||
if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil {
|
||||
t.Fatal("200001 cells should be rejected")
|
||||
}
|
||||
// The pathological case from the review (2.6M cells) → rejected.
|
||||
if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil {
|
||||
t.Fatal("2.6M-cell fan-out should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate,
|
||||
// batch-update operation count (follow-up to the single fan-out cap) ---
|
||||
|
||||
// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap:
|
||||
// buildSheetMatrix builds the whole matrix in memory, so the total cell count is
|
||||
// bounded before that allocation, summed across all sheets.
|
||||
func TestTablePutCellBudgetCap(t *testing.T) {
|
||||
// 1000×1000 = 1,000,000 == cap → allowed.
|
||||
atCap := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Columns: make([]tableColumnSpec, 1000),
|
||||
Rows: make([][]interface{}, 1000),
|
||||
}}}
|
||||
if err := atCap.checkCellBudget(); err != nil {
|
||||
t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err)
|
||||
}
|
||||
// 1000×1001 = 1,001,000 > cap → rejected.
|
||||
over := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Columns: make([]tableColumnSpec, 1000),
|
||||
Rows: make([][]interface{}, 1001),
|
||||
}}}
|
||||
if err := over.checkCellBudget(); err == nil {
|
||||
t.Fatal("1,001,000 cells should be rejected")
|
||||
}
|
||||
// Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap.
|
||||
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
|
||||
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
|
||||
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
|
||||
}}
|
||||
if err := twoSheets.checkCellBudget(); err == nil {
|
||||
t.Fatal("1.2M cells across two sheets should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the
|
||||
// per-range cap can't stop many ranges from summing past the matrix ceiling.
|
||||
func TestBatchStampAggregateCap(t *testing.T) {
|
||||
if err := checkBatchStampBudget(maxStampMatrixCells); err != nil {
|
||||
t.Fatalf("aggregate == cap should pass, got: %v", err)
|
||||
}
|
||||
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
|
||||
t.Fatal("aggregate over cap should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges
|
||||
// ranges and expects the shared validateDropdownRanges cap to reject it.
|
||||
func TestBatchFanoutRangeCountCap(t *testing.T) {
|
||||
ranges := make([]string, maxBatchRanges+1)
|
||||
for i := range ranges {
|
||||
ranges[i] = "sheet1!A1"
|
||||
}
|
||||
rangesJSON, _ := json.Marshal(ranges)
|
||||
_, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", string(rangesJSON),
|
||||
"--font-weight", "bold",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "at most")
|
||||
}
|
||||
|
||||
// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap.
|
||||
func TestBatchOperationsCountCap(t *testing.T) {
|
||||
ops := make([]interface{}, maxBatchOperations+1)
|
||||
for i := range ops {
|
||||
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
|
||||
}
|
||||
_, err := translateBatchOperations(ops, testURL)
|
||||
if err == nil || !strings.Contains(err.Error(), "at most") {
|
||||
t.Fatalf("expected operations count cap error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same
|
||||
// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB /
|
||||
// 5.3M allocs is now rejected up front, allocating only the error string.
|
||||
func BenchmarkStampBudget_RejectsOversized(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil {
|
||||
b.Fatal("expected rejection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDownloadReader_StringCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, true) }
|
||||
func BenchmarkDownloadReader_BytesNoCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, false) }
|
||||
func BenchmarkDownloadReader_StringCopy_16MB(b *testing.B) { benchDownloadReader(b, 16<<20, true) }
|
||||
func BenchmarkDownloadReader_BytesNoCopy_16MB(b *testing.B) {
|
||||
benchDownloadReader(b, 16<<20, false)
|
||||
}
|
||||
@@ -70,7 +70,6 @@ func shortcutList() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
// lark_sheet_workbook
|
||||
WorkbookInfo,
|
||||
RevisionGet,
|
||||
SheetCreate,
|
||||
SheetDelete,
|
||||
SheetRename,
|
||||
@@ -96,9 +95,6 @@ func shortcutList() []common.Shortcut {
|
||||
DimUngroup,
|
||||
DimMove,
|
||||
|
||||
// lark_sheet_changeset
|
||||
ChangesetGet,
|
||||
|
||||
// lark_sheet_read_data
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
@@ -109,9 +105,6 @@ func shortcutList() []common.Shortcut {
|
||||
CellsSearch,
|
||||
CellsReplace,
|
||||
|
||||
// lark_sheet_formula_verify
|
||||
FormulaVerify,
|
||||
|
||||
// lark_sheet_write_cells
|
||||
CellsSet,
|
||||
CellsSetStyle,
|
||||
@@ -155,10 +148,5 @@ func shortcutList() []common.Shortcut {
|
||||
CellsBatchClear,
|
||||
DropdownUpdate,
|
||||
DropdownDelete,
|
||||
|
||||
// lark_sheet_history
|
||||
HistoryList,
|
||||
HistoryRevert,
|
||||
HistoryRevertStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVGlide,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
|
||||
144
shortcuts/slides/slides_create_svglide.go
Normal file
144
shortcuts/slides/slides_create_svglide.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/svglide"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesCreateSVGlide manages a local Codex-mediated SVGlide SVG run directory.
|
||||
var SlidesCreateSVGlide = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create-svglide",
|
||||
Description: "Create and manage a local SVGlide SVG run directory",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{},
|
||||
LocalOnly: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "action", Desc: "runtime action: init, status, next, complete, author, validate, preview, quality, repair", Required: true, Enum: []string{"init", "status", "next", "complete", "author", "validate", "preview", "quality", "repair"}},
|
||||
{Name: "run", Desc: "existing run directory for status/next/complete/author/validate/preview/quality/repair"},
|
||||
{Name: "title", Desc: "deck title for init"},
|
||||
{Name: "input", Desc: "local source markdown/text path for init"},
|
||||
{Name: "audience", Desc: "final audience for the deck"},
|
||||
{Name: "delivery-mode", Desc: "delivery mode: presented, self_read, dual_mode", Enum: []string{"presented", "self_read", "dual_mode"}},
|
||||
{Name: "pages", Type: "int", Desc: "target page count"},
|
||||
{Name: "out", Desc: "output run directory for init"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "allow init to overwrite an existing run directory"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
if action == "init" {
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title is required for init").WithParam("--title")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("input")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input is required for init").WithParam("--input")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("out")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--out is required for init").WithParam("--out")
|
||||
}
|
||||
if stat, err := runtime.FileIO().Stat(runtime.Str("input")); err != nil {
|
||||
return common.WrapInputStatErrorTyped(err, "cannot read --input")
|
||||
} else if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be a regular file").WithParam("--input")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("run")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--run is required for %s", action).WithParam("--run")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
switch action {
|
||||
case "init":
|
||||
out := runtime.Str("out")
|
||||
if err := svglide.InitRun(out, svglide.InitOptions{
|
||||
Title: runtime.Str("title"),
|
||||
Input: runtime.Str("input"),
|
||||
Audience: runtime.Str("audience"),
|
||||
DeliveryMode: runtime.Str("delivery-mode"),
|
||||
Pages: runtime.Int("pages"),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := svglide.InspectStatus(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]any{
|
||||
"action": action,
|
||||
"run": out,
|
||||
"next_command": status.NextCommand,
|
||||
}, nil)
|
||||
return nil
|
||||
case "status":
|
||||
report, err := svglide.InspectStatus(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "next":
|
||||
report, err := svglide.NextTask(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "complete":
|
||||
report, err := svglide.CompleteCurrentStage(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "author":
|
||||
report, err := svglide.AuthorSlides(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "validate":
|
||||
report, err := svglide.ValidateRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "preview":
|
||||
report, err := svglide.WritePreview(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "quality":
|
||||
report, err := svglide.CheckQuality(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "repair":
|
||||
report, err := svglide.RepairRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --action %q", action).WithParam("--action")
|
||||
}
|
||||
},
|
||||
}
|
||||
486
shortcuts/slides/slides_create_svglide_test.go
Normal file
486
shortcuts/slides/slides_create_svglide_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
)
|
||||
|
||||
func TestSlidesCreateSVGlideInitShortcut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--audience", "产品负责人",
|
||||
"--delivery-mode", "self_read",
|
||||
"--pages", "8",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["action"] != "init" {
|
||||
t.Fatalf("action = %v, want init", data["action"])
|
||||
}
|
||||
if data["run"] != "run-demo" {
|
||||
t.Fatalf("run = %v, want run-demo", data["run"])
|
||||
}
|
||||
if !strings.Contains(stringValue(data["next_command"]), "--action next --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want next action", data["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRejectsPositionalAction(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"init",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected positional argument rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideStatusAndNextActions(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "status",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statusData := decodeShortcutData(t, stdout)
|
||||
if statusData["current_stage"] != "request" {
|
||||
t.Fatalf("current_stage = %v, want request", statusData["current_stage"])
|
||||
}
|
||||
if !strings.Contains(stringValue(statusData["next_command"]), "--action next --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want next action", statusData["next_command"])
|
||||
}
|
||||
|
||||
err = runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "next",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextData := decodeShortcutData(t, stdout)
|
||||
if nextData["stage"] != "request" || nextData["prompt_manifest"] != "prompt_manifest.json" {
|
||||
t.Fatalf("next data = %+v, want request prompt manifest", nextData)
|
||||
}
|
||||
if nextData["mode"] != "execution" {
|
||||
t.Fatalf("mode = %v, want execution", nextData["mode"])
|
||||
}
|
||||
if nextData["approval_required"] != false {
|
||||
t.Fatalf("approval_required = %v, want false", nextData["approval_required"])
|
||||
}
|
||||
if nextData["blocking_owner"] != "svglide-runtime" {
|
||||
t.Fatalf("blocking_owner = %v, want svglide-runtime", nextData["blocking_owner"])
|
||||
}
|
||||
if _, ok := nextData["blocking_reason"]; ok {
|
||||
t.Fatalf("blocking_reason should be omitted when empty: %+v", nextData)
|
||||
}
|
||||
if nextData["prompt_path"] != nil && stringValue(nextData["prompt_path"]) != "" {
|
||||
t.Fatalf("prompt_path = %v, want empty deprecated field", nextData["prompt_path"])
|
||||
}
|
||||
paths := valuesAsStrings(nextData["prompt_paths"])
|
||||
joined := strings.Join(paths, "\n")
|
||||
for _, want := range []string{
|
||||
"skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md",
|
||||
"skills/lark-slides/references/anygen-svg/svg_reference.md",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("prompt_paths missing %q: %+v", want, paths)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompt_manifest.json")); err != nil {
|
||||
t.Fatalf("missing prompt manifest: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompts")); !os.IsNotExist(err) {
|
||||
t.Fatalf("prompts dir err = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideLocalOnlySkipsConfigAndSDK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Config = func() (*core.CliConfig, error) {
|
||||
t.Fatal("local-only +create-svglide must not load config")
|
||||
return nil, nil
|
||||
}
|
||||
f.LarkClient = func() (*lark.Client, error) {
|
||||
t.Fatal("local-only +create-svglide must not create Lark SDK client")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != true {
|
||||
t.Fatalf("ok = %v, want true; data=%+v", data["ok"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlidePreviewActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "preview",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatalf("missing preview receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideActionEnumIncludesCompleteAuthorAndRepair(t *testing.T) {
|
||||
actionFlag := findSVGlideShortcutFlag(t, "action")
|
||||
want := map[string]bool{
|
||||
"complete": false,
|
||||
"author": false,
|
||||
"quality": false,
|
||||
"repair": false,
|
||||
}
|
||||
for _, value := range actionFlag.Enum {
|
||||
if _, ok := want[value]; ok {
|
||||
want[value] = true
|
||||
}
|
||||
}
|
||||
for value, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("action enum missing %q: %+v", value, actionFlag.Enum)
|
||||
}
|
||||
if !strings.Contains(actionFlag.Desc, value) {
|
||||
t.Fatalf("action desc %q missing %q", actionFlag.Desc, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRepairActionAuthorsAndPreviews(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "repair",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if data["reauthored"] != true {
|
||||
t.Fatalf("reauthored = %v, want true; data=%+v", data["reauthored"], data)
|
||||
}
|
||||
if data["quality"] != "passed" {
|
||||
t.Fatalf("quality = %v, want passed; data=%+v", data["quality"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "quality_report.json")); err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "validate_preview_repair.json")); err != nil {
|
||||
t.Fatalf("missing final repair receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideQualityActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "quality",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "quality_report.json")); err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideCompleteActionAdvancesRequestStage(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "complete",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["current_stage"] != "research" {
|
||||
t.Fatalf("current_stage = %v, want research; data=%+v", data["current_stage"], data)
|
||||
}
|
||||
receiptPath := filepath.Join(dir, "run-demo", "receipts", "request.json")
|
||||
raw, err := os.ReadFile(receiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt["stage"] != "request" || receipt["status"] != "done" {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideAuthorActionWritesSVG(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "author",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "done" {
|
||||
t.Fatalf("status = %v, want done; data=%+v", data["status"], data)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("run-demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing authored SVG: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `slide:role="slide"`) {
|
||||
t.Fatalf("authored SVG missing slide role:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionDoesNotErrorOnValidationFailure(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/missing.svg")
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != false {
|
||||
t.Fatalf("ok = %v, want false; data=%+v", data["ok"], data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRegistered(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Command == "+create-svglide" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("slides +create-svglide shortcut is not registered")
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithDeck(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "slides", "01.svg"), svglideShortcutVisibleTextSVG())
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithAuthorInputs(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "brief", "visual_system.json"), `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "research", "sources.json"), `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "content", "slide_content.json"), `{"slides":[{"id":"cover","content":"Point A\nPoint B","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-cover","type":"none","instruction":"Text-only"}]}]}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "assets", "assets_plan.json"), `{"assets":[]}`)
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
if err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func findSVGlideShortcutFlag(t *testing.T, name string) common.Flag {
|
||||
t.Helper()
|
||||
for _, flag := range SlidesCreateSVGlide.Flags {
|
||||
if flag.Name == name {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing flag %q", name)
|
||||
return common.Flag{}
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutDeck(t *testing.T, slidePath string) {
|
||||
t.Helper()
|
||||
deck := map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": []map[string]string{{
|
||||
"id": "cover",
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "cover",
|
||||
"key_message": "Message",
|
||||
"path": slidePath,
|
||||
}},
|
||||
}
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "outline", "deck.json"), string(raw))
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func svglideShortcutVisibleTextSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
var b bytes.Buffer
|
||||
_ = json.NewEncoder(&b).Encode(value)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func valuesAsStrings(value any) []string {
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if text, ok := value.(string); ok {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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。"
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
|
||||
---
|
||||
|
||||
# docs
|
||||
@@ -45,7 +45,6 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# 飞书思维笔记(Mindnote)
|
||||
|
||||
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
|
||||
|
||||
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
|
||||
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
|
||||
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先看命令帮助
|
||||
lark-cli mindnotes nodes list --help
|
||||
lark-cli mindnotes nodes create --help
|
||||
|
||||
# 读取节点列表
|
||||
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
|
||||
|
||||
# 创建子节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
|
||||
|
||||
# 更新已有节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
### `mindnotes nodes list`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
|
||||
返回重点:`data.nodes` 中常见字段有 `node_id`、`parent_id`、`texts`、`notes`、`images`、`finish`、`highlight`。
|
||||
|
||||
### `mindnotes nodes create`
|
||||
|
||||
命令参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
| `--data` | 是 | JSON 请求体 |
|
||||
|
||||
请求体字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `client_token` | 否 | 幂等 token,建议写操作传入;推荐使用时间戳或 UUID |
|
||||
| `nodes` | 是 | 待创建或更新的节点数组 |
|
||||
| `nodes[].node_id` | 否 | 节点 ID;传入已有 `node_id` 时表示更新对应节点 |
|
||||
| `nodes[].parent_id` | 否 | 父节点 ID;创建子节点时传入 |
|
||||
| `nodes[].texts` | 否 | 节点正文富文本数组 |
|
||||
| `nodes[].notes` | 否 | 节点备注富文本数组 |
|
||||
| `nodes[].images` | 否 | 节点图片列表 |
|
||||
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
|
||||
| `nodes[].finish` | 否 | 节点完成状态 |
|
||||
|
||||
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
|
||||
|
||||
```json
|
||||
[{"element_type":"text","text":{"content":"节点内容"}}]
|
||||
```
|
||||
|
||||
### 节点图片(`nodes[].images`)
|
||||
|
||||
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
|
||||
|
||||
```bash
|
||||
# 先上传图片,拿到 token
|
||||
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
|
||||
|
||||
# 再把 token 写进节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地图片路径 |
|
||||
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
|
||||
| `--parent-node` | 是 | 传 Mindnote 的 token |
|
||||
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
1. 先判断用户目标是不是“新建一个思维笔记”。
|
||||
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
3. 如果是操作已有思维笔记,先通过 token 类别判断。
|
||||
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`。
|
||||
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`。
|
||||
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`。
|
||||
7. 再执行 `mindnotes nodes create`。
|
||||
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID,避免重试时重复创建或重复更新。
|
||||
|
||||
> [!CAUTION]
|
||||
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
|
||||
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 3.0.1
|
||||
version: 3.0.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -32,118 +32,52 @@ metadata:
|
||||
| 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` |
|
||||
| 浮动图片 | `--float-image-id` | | |
|
||||
|
||||
## 飞书表格编辑准则(动手前必守,所有编辑类任务一律生效)
|
||||
|
||||
下列准则横切所有飞书表格任务,**动手前先过一遍**——即使你是被索引直接路由进某个工具参考也一律生效。每条只给一句话纲要,展开与边界见括注的 reference。
|
||||
|
||||
1. **最小改动**:除任务要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式 1:1 保持;中间结果放原数据右侧或新建空白 Sheet,**禁止删 / 改名 / 隐藏 / 移动已存在 Sheet**;改写类任务精确圈定行列,不该转的原值 1:1 保留。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,写完用 `+csv-get` / `+cells-get` / `+<对象>-list` 回读确认实际生效——**写操作返回 `ok` 只代表请求被接受、不代表结果符合预期**;写公式后查错误码、筛选 / 排序后核对前几行、删除 / 清空后确认已空。禁止只在文本里声称"已完成"。
|
||||
3. **读全再写**:批量填充 / 补齐 / 修正类任务先确认真实数据末行再写,只探前 N 行会漏写表尾(确定末行流程见 `lark-sheets-read-data`)。
|
||||
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找)一律写公式而非静态值;**凡可由表内其它单元格推导的派生值默认就用公式,即使用户没说"联动 / 自动更新"**;写任何飞书公式前先读 `lark-sheets-formula-translation`。
|
||||
5. **续写 / 扩展继承样式**:续写、补齐、复制区块、新增行列时禁止只读值只写值,必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承(清单见 `lark-sheets-write-cells`,四边框最易漏)。
|
||||
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`(语义见 `lark-sheets-batch-update`)。
|
||||
7. **分组汇总用透视表**:"按 X 统计 Y / 分组汇总 / 各类数量金额"用 `+pivot-{create|update|delete}`,禁止用 SUMIF / 本地脚本拼一张假透视表。
|
||||
8. **拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",逐点 `assert` 全过才交付(多维排序每维一点、多目标每目标一点、范围类核起 / 末 / 边界);只做第一个要点属违规。
|
||||
9. **全量处理前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,先把预期条数硬编码再 `assert actual == expected`,禁止输出"已完成前 N 条,剩余继续"的半成品。
|
||||
|
||||
> 上述准则的实操展开——读取路径、原生工具优先级、脚本配合、易漏陷阱——见下方「执行要点」节;端到端工作流为:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
|
||||
|
||||
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
|
||||
|
||||
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。**选定命令后别急着写——先读「动手前读」列指向的 reference 再动手**:命令名对得上不代表用法对,写入 / 清除 / 透视类尤其容易漏掉 reference 里的防错、类型与样式继承规则。
|
||||
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。
|
||||
|
||||
| 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) |
|
||||
| --- | --- | --- | --- |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `lark-sheets-read-data` | `+get-range`、`+range-get`、`+cells-read` |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | `lark-sheets-write-cells` | — |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数等**本质是量值**的数据——不看当下要不要排序 / 求和,量值一律走这里) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理;要同时美化加 `--styles` 一步带样式(区域底色 / 边框 / 列宽 / 行高 / 合并),不必事后再刷;payload 里不存在的 sheet 名会自动建子表,详见 write-cells) | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力;常见借口见下方 ⚠️) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢;`--styles` 同样可在建表同一步带全套样式,详见 workbook) | `lark-sheets-workbook` | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(定位用 `--range`;批注 / 图片 / 富文本只能用它,公式也可) | `lark-sheets-write-cells` | — |
|
||||
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | `lark-sheets-write-cells` | — |
|
||||
| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | `lark-sheets-float-image` | — |
|
||||
| 查找 / 替换文本 | `+cells-search`(找,关键字用 `--find`)、`+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find`、`+find`、`--query` |
|
||||
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `lark-sheets-sheet-structure` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` |
|
||||
| 看工作簿 / 子表清单 | `+workbook-info` | `lark-sheets-workbook` | `+sheet-list`、`+workbook-get`、`+workbook-list` |
|
||||
| 复核某次(AI)编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20) | — |
|
||||
| 取当前文档 revision(版本号) | `+revision-get` | `lark-sheets-workbook` | — |
|
||||
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
|
||||
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`) |
|
||||
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `lark-sheets-range-operations` | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `lark-sheets-batch-update` | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `lark-sheets-range-operations` | `--dimension`(无此 flag) |
|
||||
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | `lark-sheets-pivot-table` | 用 SUMIF / 本地脚本拼一张假透视表 |
|
||||
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create` | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
|
||||
| 条件高亮 / 数据条 / 色阶 / 重复值标记 | `+cond-format-create` | `lark-sheets-conditional-format` | `+highlight`、`+conditional-format`、逐格 `+cells-set-style` 硬凑 |
|
||||
| 筛选 / 只看符合条件的行 | `+filter-create` | `lark-sheets-filter` | pandas filter 后覆盖写回(会毁原数据;要保存多份筛选状态用 `+filter-view-create`) |
|
||||
| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) |
|
||||
| --- | --- | --- |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `+get-range`、`+range-get`、`+cells-read` |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells) | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢,详见 workbook) | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — |
|
||||
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | — |
|
||||
| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | — |
|
||||
| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` |
|
||||
| 查找并替换 | `+cells-replace` | — |
|
||||
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` |
|
||||
| 看工作簿 / 子表清单 | `+workbook-info` | `+sheet-list`、`+workbook-get`、`+workbook-list` |
|
||||
| 导出 xlsx / 单表 csv | `+workbook-export` | — |
|
||||
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌 |
|
||||
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag) |
|
||||
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
|
||||
|
||||
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**:本次操作只要**涉及样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 汇总行 / 配色 / 列宽行高),动手前先读 `lark-sheets-visual-standards`;只要**要写飞书公式**,动手前先读 `lark-sheets-formula-translation`(飞书函数与 Excel 有差异,凭直觉迁移易错)。哪怕主任务是"建表 / 展开数据 / 录入",只要动作里含美化或写公式就适用——别因"这不算专门的美化 / 公式任务"而跳过。
|
||||
> ⚠️ **两种图片别选错**:图若**绑定某条记录、要随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`;只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
|
||||
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 比率 / 计数 / 日期等**本质是量值**的数据 → 一律数值写入,常规二维表用 `+table-put`(`dtypes` 声明类型 + `formats` 设展示格式),版式装不下(多级 / 合并表头的宽表 leaderboard 等)改用 `+cells-set` 传数字(百分比传小数 `0.4`)+ `number_format`,照样显示 `40%` 且数值无损。只有编号 / 身份证 / 单据号这类**本质是标识符**、要字面保真的才用 `+csv-put` 平铺。**几个常见借口都不成立**——"只是 leaderboard / 报表展示不用算""版式复杂""样式以后再刷、先铺文本"都不是把百分比写成 `"40%"` 字符串灌 `+csv-put` 的理由(展示不改变它是数值;类型不能后补,落成文本就回不来)。判据与操作展开见 `lark-sheets-write-cells`「数字还是文本」。
|
||||
> ⚠️ **要新建子表 / 整表美化 → 别默认「`+csv-put` 写值再事后刷样式」**:`+table-put` / `+workbook-create` 的 `--styles` 能在写数据的**同一步**带全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并),且 `+table-put` 的 payload 里若 sheet 名不在工作簿中会自动新建子表——**纯文本表要新建子表 + 美化时同样走这里**(`--styles` 与列是否 typed 无关),比「`+csv-put` 写值 + 多次 `+cells-batch-set-style` / `+*-resize` 刷样式」少好几次调用(冻结行列等 sheet 级属性仍需 `+dim-freeze` 单独一步)。
|
||||
> ⚠️ **纯文本还是数值语义**:要写的列里有数字 / 金额 / 百分比 / 日期 / 计数 → `+table-put`(写入已有表;外层 `{"sheets":[...]}` 包裹、列 pandas dtype 用 `dtypes`、展示格式用 `formats`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`);只有纯文本才用 `+csv-put`。两者写完显示可以完全相同,但 `+csv-put` 落的是文本、不能参与计算——别把数值在本地拼成带 `$` / `%` 的字符串再走 `+csv-put`。
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
|
||||
|
||||
## 执行要点(读取 / 原生工具 / 陷阱)
|
||||
|
||||
准则的实操展开。端到端工作流:了解结构 → 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
|
||||
|
||||
### 读取:按需求选路径(细则见 `lark-sheets-read-data`)
|
||||
|
||||
| 用户需求 | 读取路径 |
|
||||
|---|---|
|
||||
| "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准) |
|
||||
| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
|
||||
| 需要公式 / 样式 / 批注 | `+cells-get` |
|
||||
| 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5) |
|
||||
|
||||
> "补齐 / 填空"类用只读路径探 10 行就写会漏写表尾——写入前先按 `lark-sheets-read-data` 确认真实数据末行(准则 3)。
|
||||
|
||||
### 计算:原生工具优先,代码兜底(强化准则 7)
|
||||
|
||||
| 用户需求 | 用原生 | 禁止的替代 |
|
||||
|---|---|---|
|
||||
| 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → 写值 |
|
||||
| 求和 / 计数 / 平均 / 占比 | 公式 | Python 算 → 写静态值 |
|
||||
| 图表 / 可视化 | `+chart-*` | matplotlib |
|
||||
| 条件高亮 / 色阶 | `+cond-format-*` | 逐格设样式 |
|
||||
| 筛选 | `+filter-*` | pandas filter → 覆盖写入 |
|
||||
| 文本提取 / 转换 / 查找 | 公式(REGEXEXTRACT / TEXT / VLOOKUP 等) | Python → 写静态值 |
|
||||
|
||||
只有多步清洗、统计建模、公式试错 3 次仍失败时才用代码。
|
||||
|
||||
### 用脚本配合 CLI 时
|
||||
|
||||
- **只读 stdout**:CLI 数据走 stdout、诊断走 stderr;解析 JSON 别 `2>&1`(警告混入会解析失败),用管道或单独重定向 stdout。
|
||||
- **喂 CLI 的 CSV / JSON 用 UTF-8 无 BOM**;临时文件放系统临时目录、勿落项目目录。
|
||||
- **命令失败先读 stderr 再调整**,别原样重发。
|
||||
- **回写纯单元格值**:剥离 `值(V-Align: bottom)` 这类"值(样式)"串与残留引号再写;排序优先 `+range-sort` 原生工具,别"读出本地排完再整列写回"。
|
||||
|
||||
### 易漏陷阱
|
||||
|
||||
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框,新行回落默认高度截断长文本;插行填长文本前读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。
|
||||
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首末各 5 行查 `#VALUE!` / `#REF!` / `#DIV/0!`;同一方案试错上限 3 次。
|
||||
- **循环引用**:聚合公式引用范围不能含目标 cell 自身或其传递依赖。
|
||||
- **隐藏行列**:`+csv-get` 默认含隐藏行列;设 `--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
|
||||
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。
|
||||
- **NLP 任务分批**:语义理解 / 翻译 / 改写 / 分类等用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量大必须分批(通常 30 行 / 批),每批处理完即时写回,单批生成通常 ≤ 300 行,多批用 `+batch-update`。
|
||||
|
||||
## References
|
||||
|
||||
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
|
||||
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。
|
||||
|
||||
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut)
|
||||
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框、数字格式等取值标准,以及从零新建表格的版式美化、新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。 |
|
||||
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。 |
|
||||
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。 |
|
||||
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。 |
|
||||
|
||||
### 按对象的工具参考(含 shortcut)
|
||||
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [飞书表格公式自检](references/lark-sheets-formula-verify.md) | 公式写入后的自检入口。对指定子表(或整本工作簿)扫描公式与单元格值,聚合所有 Excel 错误(#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A),同时合并最近一次写入留下的编译失败(formula_errors),输出统一 JSON 让 AI 一次拿到完整健康度报告。任何批量公式 / 含公式列写入完成后调用 +formula-verify 确认 zero-error;status='errors_found' 时禁止把链路标为完成。 |
|
||||
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 |
|
||||
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 |
|
||||
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 |
|
||||
@@ -158,8 +92,6 @@ metadata:
|
||||
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
|
||||
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
|
||||
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
|
||||
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本并回滚到指定版本。当用户需要查看一张表的编辑历史版本列表、回滚到某个历史版本、或查询回滚的异步状态(进行中/成功/失败)时使用。回滚为异步操作,发起后通过状态查询轮询结果。仅针对飞书表格。 |
|
||||
| [Lark Sheet Changeset](references/lark-sheets-changeset.md) | 读取两个版本(CS revision)之间的 changeset(原始变更操作清单),用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20;返回里最外层带当前表格最新版本号。当用户需要"看看这次改了什么"、"核对 AI 改动"、"对比两个版本的变更"时使用。 |
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
|
||||
@@ -51,10 +51,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A1:B2","'Sheet2'!D1:D10"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style |
|
||||
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) |
|
||||
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) |
|
||||
| `--font-family` | string | optional | 字体名称(如 `Arial`、`微软雅黑`) |
|
||||
| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) |
|
||||
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) |
|
||||
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) |
|
||||
@@ -71,7 +70,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["Sheet1!A2:A100","Sheet1!C2:C100"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["'Sheet1'!A2:A100","'Sheet1'!C2:C100"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
|
||||
| `--options` | string + File + Stdin(复合 JSON) | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 |
|
||||
| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 |
|
||||
| `--multiple` | bool | optional | 启用多选 |
|
||||
@@ -84,7 +83,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["Sheet1!E2:E6"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["'Sheet1'!E2:E6"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
|
||||
|
||||
### `+cells-batch-clear`
|
||||
|
||||
@@ -92,7 +91,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A2:Z1000","Sheet2!A2:Z1000"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A2:Z1000","'Sheet2'!A2:Z1000"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 |
|
||||
| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) |
|
||||
|
||||
## Schemas
|
||||
@@ -138,7 +137,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
|
||||
|
||||
# ops.json (array<{shortcut, input}>,shortcut 用 CLI 名):
|
||||
# [
|
||||
# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","position":10,"count":3}},
|
||||
# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}},
|
||||
# {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}}
|
||||
# ]
|
||||
```
|
||||
@@ -146,7 +145,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
|
||||
> ⚠️ **子操作定位规则**:
|
||||
> - spreadsheet 定位(`--url` / `--spreadsheet-token`)**只在顶层给一次**;`+batch-update` 顶层**没有** `--sheet-id` / `--sheet-name`,在顶层传不生效。
|
||||
> - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item)。
|
||||
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"position":11`),不要把整组 `--operations` 再套一层嵌套 JSON。
|
||||
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"dimension":"row"`),不要把整组 `--operations` 再套一层嵌套 JSON。
|
||||
|
||||
> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。
|
||||
>
|
||||
@@ -154,7 +153,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
|
||||
> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行
|
||||
> [
|
||||
> {"shortcut": "+dim-insert",
|
||||
> "input": {"sheet_id": "...", "position": "C", "count": 1}},
|
||||
> "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}},
|
||||
> {"shortcut": "+cells-set",
|
||||
> "input": {"sheet_id": "...", "range": "C1:C100",
|
||||
> "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# Lark Sheet Changeset
|
||||
|
||||
## 使用场景
|
||||
|
||||
读取两个版本之间的 **changeset(变更操作清单)**,用于**复核某次编辑(尤其是 AI 编辑)是否真实满足用户诉求**。
|
||||
|
||||
典型场景:AI agent 对表格做了一批编辑后,想确认它"说做的"和"真正落到表格上的"是否一致——拉取编辑前版本到编辑后版本之间的 changeset,逐条核对 action 是否覆盖了用户要求的修改、有没有多改 / 漏改。
|
||||
|
||||
## 版本(revision)语义
|
||||
|
||||
- 这里的"版本"指表格的 **CS revision**(每次提交单调递增的修订号),不是文档历史里的命名版本。
|
||||
- `--start-revision` 是复核基线,即你认定的"编辑前"版本。
|
||||
- `--end-revision` 是"编辑后"版本;**省略时默认取最新 revision**,返回从 start 到最新的全部 changeset。
|
||||
- **版本差上限 20**:`end - start + 1 ≤ 20`,超出会被拒绝(服务端同样以 20 兜底)。复核大跨度变更时请分段拉取。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+changeset-get` | read | 变更记录 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+changeset-get`
|
||||
|
||||
_公共:URL/token(无 sheet 定位)_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--start-revision` | int | required | 起始版本(编辑前基线,>= 1) |
|
||||
| `--end-revision` | int | optional | 结束版本(省略取最新) |
|
||||
|
||||
## 返回结构
|
||||
|
||||
返回一个 JSON 对象,`changesets` 数组按版本顺序排列,每个元素是一次提交的**原始 action 列表**与元信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"spreadsheet_token": "shtcnXXXX",
|
||||
"latest_revision": 142,
|
||||
"start_revision": 120,
|
||||
"end_revision": 135,
|
||||
"changesets": [
|
||||
{
|
||||
"revision": 121,
|
||||
"create_time": "2026-06-12T10:00:00Z",
|
||||
"actions": [
|
||||
{ "action": "setCellRange", "sheetId": "...", "value": { /* ... */ } }
|
||||
],
|
||||
"is_self_edit": false,
|
||||
"is_ai_edit": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 最外层 `latest_revision` 是**当前表格的最新版本号**(与查询区间无关),便于判断表格当前停在哪个版本、`--start-revision` 该取多少。
|
||||
- `actions` 是**未经语义渲染的原始操作对象**,按提交内的执行顺序排列。复核时逐条比对:每个 action 改了哪个 sheet、哪个区域、改成什么,是否对应用户的诉求。
|
||||
- `revision` / `create_time` 用于判断"这次改动属于哪个版本、什么时候做的"。
|
||||
- `is_self_edit` 表示该 changeset 是否由当前请求用户提交(committer 与请求用户相同),即"是不是我自己提交的编辑"。
|
||||
- `is_ai_edit` 表示该 changeset 是否由 AI 客户端提交(`member_id` 为 10 / 11)。复核时 `is_ai_edit=true` 即为 AI 写入的编辑(而非用户手动编辑),是核对 AI 是否完成诉求的主要对象。
|
||||
|
||||
## 复核工作流(判断 AI 是否真实完成诉求)
|
||||
|
||||
1. 记下 AI 开始编辑前的 revision(编辑前 `+workbook-info` 或上一次工具返回的 revision 即可作为 `--start-revision`)。
|
||||
2. AI 编辑完成后,跑 `+changeset-get --url <表格> --start-revision <编辑前版本>`(不传 end → 取到最新)。
|
||||
3. 遍历 `changesets[].actions`,核对:
|
||||
- 用户要求的每一处修改是否都有对应 action;
|
||||
- 有没有越权 / 多余的修改(动了用户没让动的 sheet / 区域);
|
||||
- action 的目标区域、值是否与诉求一致。
|
||||
4. 若版本跨度可能 > 20,分段拉取(如 `start..start+19`、`start+20..` …)。
|
||||
|
||||
## 注意
|
||||
|
||||
- `+changeset-get` 是**只读**操作,不改动表格。
|
||||
- 大跨度 / 大批量编辑的 changeset 可能体积较大;输出在传输层已 gzip。必要时缩小版本区间。
|
||||
- 该工具走只读 scope `sheets:spreadsheet:read`,需要对表格有查看权限。
|
||||
|
||||
## Examples
|
||||
|
||||
### `+changeset-get`
|
||||
|
||||
公共:`--url` / `--spreadsheet-token`(二选一,无 sheet 定位)。changeset 是工作簿级历史,不接受 sheet 定位 flag。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
# 只传起始版本 → 返回从该版本到最新的全部 changeset(最常用:复核 AI 编辑前后的差异)
|
||||
lark-cli sheets +changeset-get --url "https://example.feishu.cn/sheets/shtXXX" --start-revision 120
|
||||
|
||||
# 传起始 + 结束版本(版本差 end-start+1 ≤ 20)
|
||||
lark-cli sheets +changeset-get --spreadsheet-token shtXXX --start-revision 120 --end-revision 135
|
||||
```
|
||||
|
||||
输出契约(envelope.data):
|
||||
|
||||
- `latest_revision` — 当前表格最新版本号(与查询区间无关)
|
||||
- `start_revision` / `end_revision` — 实际查询区间(省略 `--end-revision` 时 `end_revision` = 最新版本)
|
||||
- `changesets[]` — 按版本顺序排列;每项含 `revision` / `create_time` / `actions`(原始操作列表)/ `is_self_edit` / `is_ai_edit`
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate` 阶段只做 XOR 检查(`--url` / `--spreadsheet-token` 二选一)与版本上限校验(`--start-revision ≥ 1`,传了 `--end-revision` 时 `end ≥ start` 且 `end - start + 1 ≤ 20`);**禁止**联网。
|
||||
- `DryRun` 输出请求模板,不实际拉取 changeset。
|
||||
- `Execute` 阶段才发起 changeset 查询;省略 `--end-revision` 时由服务端解析为最新 revision。
|
||||
@@ -122,7 +122,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`);必须至少含 `snapshot.data.dim1.serie.index` 或 `dim2.series[].index` 之一,否则 server 拒。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`)。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
|
||||
|
||||
### `+chart-update`
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@
|
||||
|
||||
**正确做法(两步走)**:
|
||||
|
||||
Step 1 的 `+cells-set` 及 `--copy-to-range` 等 flag 以 `lark-sheets-write-cells` 为准。
|
||||
|
||||
```
|
||||
Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列)
|
||||
range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100"
|
||||
|
||||
103
skills/lark-sheets/references/lark-sheets-core-operations.md
Normal file
103
skills/lark-sheets/references/lark-sheets-core-operations.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 飞书表格核心操作:分析、编辑与可视化
|
||||
|
||||
## 概览
|
||||
|
||||
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应 reference,本文用指针引到那里,不重复展开。
|
||||
|
||||
**三份「通用方法与规范」如何分工**(都不含 shortcut,按主题单一归属):
|
||||
|
||||
- **本文(core-operations)= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。
|
||||
- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。
|
||||
- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。
|
||||
|
||||
> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。
|
||||
|
||||
## 铁律(所有编辑类任务必须满足,各 reference 不得放宽)
|
||||
|
||||
1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。**改写 / 转换类任务要精确圈定适用行列**:只对任务真正要求的对象做变换,**不该转的行 / 列保持原值 1:1**(典型反例:要求"统一翻译"时把本就是中文、应原样保留的评论也重新翻译;要求"改写某列格式"时连原始测量值也一并改动 → 应保留的原文被篡改)。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。**收尾前必须确认产物文件真实存在 / 可导出**——别在没真正生成产物时只凭文本"已完成"就结束(反例:文本称已完成,实际没生成产物文件,等于没交付)。
|
||||
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
|
||||
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。**即使用户没说"联动 / 自动更新",凡是可由表内其它单元格推导的派生值(年龄=当年-出生年、占比=本类数/总数、达标=阈值判断、排名、各类分组汇总)默认就必须用公式**——用户默认期望派生列能随源数据重算,**离线 Python / 脚本算完写静态值,即便当前数值正确,改了源数据也不会自动更新,等于没满足"派生"的本意**(反例:年龄、月度汇总、占比、分组求和等派生列写死值,源数据一改结果就过时)。
|
||||
5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。
|
||||
6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set),必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`。
|
||||
7. **分组汇总必须用透视表**:"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。
|
||||
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。**题面 / 表头里写明的格式规范也是子要点**:表头注明"需标注某字段"就必须给对应单元格加规定前缀并逐条 assert 前缀存在(反例:漏加规定前缀,该要点即不达标);"相同编号连续行合并"必须遍历所有相同编号组全部合并(反例:只合并了其中一部分组)。
|
||||
9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。
|
||||
|
||||
## 推荐工作流程
|
||||
|
||||
1. **规划 reference 清单**:开工前一次性列出本任务要读的 reference(避免读一个调一个),本轮已读过的不重复读。本文 + `lark-sheets-workbook` 几乎每次都要。
|
||||
2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure` 的 `+sheet-info`。
|
||||
3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`)**:
|
||||
|
||||
| 用户需求语义 | 路径 |
|
||||
|---|---|
|
||||
| "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:原生优先**(公式 / `+pivot` / `+filter`,见第 5 步);原生表达不了或更复杂时**分批 `+csv-get` 导出 + 本地脚本处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准;脚本与 CLI 配合见下方「CLI 配合要点」) |
|
||||
| "查一下 / 看看 / 统计 / 汇总" 等只读 | B:`+csv-get` 读到上下文 |
|
||||
| 需要公式 / 样式 / 批注 | C:`+cells-get` |
|
||||
| 续写 / 扩展 / 完善已有内容 | D:`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5) |
|
||||
|
||||
**注意**:对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace` 的 `+cells-search`。
|
||||
|
||||
4. **理解数据语义(写入前必做)**:读表头 + 3-5 行样本确认各列含义与格式(文本 / 数字 / 日期 / 混合);写公式前先分析样本值格式模式再选提取策略;建透视表前先列清"行字段=分组维度、值字段=聚合指标"。需求模糊时(如"加入加减乘除"未说逻辑)基于表头与已有公式推断,不确定就问用户,禁止臆造业务逻辑。
|
||||
|
||||
5. **分析与计算(原生工具优先,代码兜底)**:飞书原生能力能随数据自动更新,**必须优先**:
|
||||
|
||||
| 用户需求 | 必须用的原生工具 | 禁止用代码替代 |
|
||||
|---|---|---|
|
||||
| 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → `+cells-set` |
|
||||
| 求和 / 计数 / 平均 / 占比 | 公式(SUM/COUNT/AVERAGE) | Python 算 → 写静态值 |
|
||||
| 画图表 / 可视化 | `+chart-{create\|update\|delete}` | matplotlib 画图 |
|
||||
| 条件高亮 / 色阶 | `+cond-format-{create\|update\|delete}` | 逐单元格设样式 |
|
||||
| 数据筛选 | `+filter-{create\|update\|delete}` | pandas filter → 覆盖写入 |
|
||||
| 文本提取 / 转换 | 公式(REGEXEXTRACT/TEXT/VALUE) | Python 正则 → 写静态值 |
|
||||
| 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 |
|
||||
|
||||
**只有以下才用代码**:多步清洗流水线、统计建模、公式试错 3 次仍失败的降级。代码结果回写:大块纯值用 `+csv-put`(+ `--start-cell`,必要时自动扩容);少量或需公式 / 样式用 `+cells-set`;能用飞书公式表达的写飞书公式。
|
||||
|
||||
6. **写入与修改(细节见 `lark-sheets-write-cells`)**:`+cells-set` 的 `range` 必须落在已有行列范围内、`cells` 二维数组与 `range` 严格同维;表尾追加先用 `+dim-insert` 插行列再写;整列 / 整行同结构的值 / 公式 / 格式用模板单元格 + `--copy-to-range`,禁止逐行 `+cells-set`;多步写入合并为 `+batch-update`;改尺寸先读相邻可见行列当前尺寸再决定 `pixel` / `standard` / `auto`,不要猜数值。
|
||||
|
||||
7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。
|
||||
|
||||
## 用本地代码 / 脚本时的 CLI 配合要点
|
||||
|
||||
复杂处理——多步清洗、统计建模、批量转换、语义任务的分批编排等——用代码(`python` / `node` 等)解决是完全正当的。原生能力(公式 / `+pivot` / `+filter`)能表达就优先用(可随源数据自动重算);原生表达不了或逻辑更复杂时,放手用代码。下面几条让脚本与 CLI 顺畅配合:
|
||||
|
||||
- **解析输出时只读 stdout**:CLI 把数据 JSON 写到 stdout、把诊断与警告写到 stderr。解析 JSON 时**不要合并这两条流**(即不要 `2>&1`),否则警告行混进 JSON 会让解析失败。用管道(`lark-cli … | jq …`)或先把 stdout 单独重定向到文件再读;需要诊断信息时把 stderr 另导到一个文件。
|
||||
- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**:BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`。
|
||||
- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。
|
||||
- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。
|
||||
- **写回的必须是纯单元格值,禁止把"值+样式标注"串当值写回**:本地脚本或某些 xlsx 解析库会把单元格渲染成 `甲方支行(V-Align: bottom)` 这种"值(样式)"字符串,CSV 字段还可能带包裹双引号。回写前必须**剥离括号样式标注、去掉残留引号**,只写原始值——否则样式描述会变成单元格的字面文本污染原数据(反例:排序后单元格值里被写进 `(V-Align: bottom)` 这类样式后缀文本,末尾还多一个双引号)。**排序本身优先用 `+range-sort` 原生工具**,不要"读出来本地排完再整列写回",从根上避免这类回写污染。
|
||||
|
||||
## 公式策略
|
||||
|
||||
- **公式优先于硬编码**(同铁律 4):能用公式表达的计算一律写公式,源数据变化才能自动重算。
|
||||
- **写任何公式前先读 `lark-sheets-formula-translation`**:它是公式正确性的唯一权威,覆盖绝对引用(`$`)、飞书范围语法(`H:H` 与工具 A1 表示法的区别)、ARRAYFORMULA / 数组语义、Excel 迁移、不支持函数清单等全部规则。本文不再单列这些细则。
|
||||
|
||||
## 常见陷阱(铁律已覆盖的不再重复,仅列易漏点)
|
||||
|
||||
- **合并单元格**:合并区只有左上角存数据,其余读为空是正常行为;写入只能写左上角,写其它位置会报 `cell ... is inside a merged region`。改合并区先取消再操作。安全操作 5 条与"批量取消用大 range 一次调用"见 `lark-sheets-range-operations`。
|
||||
- **`+dim-insert` 不继承行高**:`--inherit-style before/after` 只继承值 / 公式 / 边框,不继承 `row_height`,新行会回落默认高度截断长文本;中间插行填文本前先读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。
|
||||
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首 5 + 末 5 行查 `#VALUE!` / `#NAME?` / `#REF!` / `#DIV/0!`;同一方案试错上限 3 次,超了改代码以值写入。
|
||||
- **循环引用**:聚合公式(SUM/AVERAGE)引用范围不能含目标 cell 自身或其传递依赖。
|
||||
- **NaN / 空值 / 除零**:空值不直接参与运算;除法用 `IF` / `IFERROR` 防零。
|
||||
- **排序 / 筛选混合文本列**:带货币符 / 单位 / 表达式的文本列直接排序 / 筛选会按字典序出错,先抽数值到辅助列再处理(细则见 `lark-sheets-range-operations` / `lark-sheets-filter`)。
|
||||
- **隐藏行列**:`+csv-get` 默认 `--skip-hidden=false`(含隐藏行列);设 `true` 只看可见数据,但返回行序号与实际行号不再对应。
|
||||
- **行号一律取 `[row=N]` 前缀**:`+csv-get` 的 CSV 中双引号内换行是单元格内换行不是新行;禁止数 `\n`、禁止用"序号列"当行号(细则见 `lark-sheets-read-data`)。
|
||||
- **列字母取 `col_indices[j]`**:禁止手数表头逗号定位列(>10 列极易 off-by-one)。
|
||||
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。
|
||||
- **`+cells-search` 不是万能**:用户说"汇总金额"是操作动作(求和),不是搜索该文本;只在确需定位某文本位置时才用。
|
||||
|
||||
## 特殊场景
|
||||
|
||||
### 续写 / 复制已有区块格式
|
||||
|
||||
核心要求见铁律 5。机制(带齐哪些样式字段、怎么采样写入)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」;样式标准(斑马纹奇偶 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。本文不再展开。
|
||||
|
||||
### NLP 任务处理
|
||||
|
||||
任务涉及语义理解、翻译、改写、摘要、分类、抽取、多行聚合时,以 NLP 方式处理,不要用纯规则代码替代语义理解(但可用代码做分批、行号映射、结果拼装与写回)。数据量大时**必须**分批(通常 30 行一批),每批处理完立即写回,不要全处理完再一次写入;单批生成通常不超 300 行,超出时按性质抽样或分批并向用户说明范围;多批写入优先用 `+batch-update` 合并为原子提交。
|
||||
|
||||
### 格式处理优先公式
|
||||
|
||||
"去除多余零 / 提取数字 / 文本格式转换 / 日期格式化"等清洗,**必须优先用公式**(`SUBSTITUTE` / `TEXT` / `VALUE` / `LEFT` / `RIGHT` / `MID` 等):写一个模板 + `--copy-to-range` 即可整列处理,远比逐行修改高效。
|
||||
@@ -50,7 +50,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| --- | --- | --- | --- |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag |
|
||||
| `--range` | string | required | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;create 必填,必须覆盖表头行 |
|
||||
| `--view-name` | string | optional | 筛选视图名称;不传时系统自动分配;优先级高于 `--properties` 中同名字段 |
|
||||
| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 |
|
||||
|
||||
### `+filter-view-update`
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
|
||||
- **`--image <本地路径>`(首选,最省事)**:直接给本地图片文件路径(PNG/JPEG/GIF/BMP/HEIC 等)。CLI 会自动把它以 `parent_type=sheet_image` 上传,拿到 file_token 后创建浮动图,**不用你手动上传 / 取 token**。路径规则同其它本地文件 flag:必须是当前工作目录内的相对路径(绝对路径会被 Validate 拒,`--dry-run` 也会拦)。
|
||||
- `--image-token`:复用**已存在**的图片 file_token。常见来源:① `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。
|
||||
- `--image-uri`:图片 URI(上传链路返回的句柄),**非**表内对象 reference_id;由系统自动转 file_token。
|
||||
- `--image-uri`:图片 reference_id(image URI),由系统自动转 file_token。
|
||||
|
||||
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`;用完清除该临时单元格,避免残留多余图片。
|
||||
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -122,7 +122,7 @@ lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
|
||||
--image-name "logo.png" --image-token "$TOKEN" \
|
||||
--position-row 0 --position-col A --size-width 200 --size-height 150
|
||||
|
||||
# 用 image URI(上传链路返回的句柄,非表内对象 reference_id;与 --image-token 二选一)
|
||||
# 用 reference_id(图片上传链路返回的 image reference_id;与 --image-token 二选一)
|
||||
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
|
||||
--image-name "logo.png" --image-uri "$IMAGE_URI" \
|
||||
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# 飞书表格公式生成规则
|
||||
|
||||
> **本文定位**:飞书公式正确性的**唯一权威**——书写任何飞书公式、或把 Excel 公式迁移到飞书前,先读本文。涵盖公式书写约定(绝对引用、范围语法)、投影 vs spill、ARRAYFORMULA / 数组语义、高风险引用函数、日期差、不支持函数清单。
|
||||
> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**(`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells`。本文不含 shortcut,通用编辑准则见主 SKILL.md「飞书表格编辑准则」。
|
||||
> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**(`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells` 与 `lark-sheets-core-operations`。本文不含 shortcut,铁律见 `lark-sheets-core-operations`。
|
||||
|
||||
**核心原则:飞书不像 Excel 365 那样默认 spill(溢出展开)。飞书普通公式遇到区域时默认"投影"(只取当前行/列对应的单个值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。**
|
||||
|
||||
## 公式书写约定(写任何公式都先满足)
|
||||
|
||||
- **绝对引用 `$`**:向下 / 向右填充前判断哪些引用要锁定——用户指定的固定 cell(`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)、锁行不锁列(`B$1`)。填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。
|
||||
- **公式字符串用飞书范围语法**:写 `H:H`、`A2:B5`,**禁止** `H2:H` / `2:2`。要在公式里引用整行,用显式范围(如 `$A2:$Z2`)替代禁用的 `2:2`。这与 CLI 工具参数(如 `--range` / `--copy-to-range`)的 A1 表示法写法不同:参数侧合法的 `D3:D`、`1:1`、`3:6` 在公式串里反而非法。**公式串 ≠ CLI 参数**,两套规则别互相照搬,混用会导致调用失败或公式报错。
|
||||
- **公式字符串用飞书范围语法**:写 `H:H`、`A2:B5`,**禁止** `H2:H` / `2:2`。这与 CLI 工具参数(如 `--range`)的 A1 表示法(`A1:D3`、`1:1`)写法不同,两者混淆会导致调用失败或公式报错。
|
||||
|
||||
## 翻译后必做:代码复现校验
|
||||
|
||||
@@ -224,7 +224,7 @@ Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入)
|
||||
|
||||
## 飞书不支持的函数
|
||||
|
||||
> 本段是"飞书不支持函数"的**唯一权威清单**。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案:
|
||||
> 本段是"飞书不支持函数"的**唯一权威清单**(`lark-sheets-core-operations` 不再单列,统一指向这里)。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案:
|
||||
|
||||
- `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据
|
||||
- `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数
|
||||
@@ -234,7 +234,6 @@ Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入)
|
||||
- `INFO`、`RTD` — 系统信息 / 实时数据函数,飞书不支持
|
||||
- `PIVOT` — 用 `+pivot-{create|update|delete}` 透视表对象替代
|
||||
- `AMORDEGRC`、`PHONETIC`、`DETECTLANGUAGE` — 飞书不支持
|
||||
- `LET`、命名自定义函数(名称管理器里定义的 LAMBDA)、独立调用的 `LAMBDA`(如 `=LAMBDA(x,x+1)(5)`)— 会报 `#NAME?`;改用嵌套 IF / 辅助列。**例外**:`LAMBDA` 作为 `MAP` / `REDUCE` / `BYROW` / `BYCOL` / `SCAN` / `MAKEARRAY` 的内联参数时**支持**(见上方「飞书原生数组函数清单」)
|
||||
|
||||
## 代表性改写示例
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# 飞书表格公式自检(+formula-verify)
|
||||
|
||||
> **本文定位**:飞书表格"公式写入后是否真的零错误"的自检入口。公式的书写规则与 Excel→飞书迁移的语义规则一律以 `lark-sheets-formula-translation` 为唯一权威,本文不重复;本文聚焦"写完了之后怎么用一次调用确认 zero-error"。
|
||||
>
|
||||
> **边界**:本文不讲公式怎么写(去 `lark-sheets-formula-translation`),也不讲公式怎么写入表格(去 `lark-sheets-write-cells` / `lark-sheets-batch-update`)。本文只讲一件事:**写完之后必须用 `+formula-verify` 自检到 zero-error 才能交付**。
|
||||
|
||||
## 为什么需要自检
|
||||
|
||||
飞书在线表格已经实时算好结果,但"算出来"和"算对了"是两件事。常见缺口:
|
||||
|
||||
- 公式编译失败 → 单元格落成文本(写入类 shortcut 返回的 `formula_errors[]` 是**编译失败**信号)。
|
||||
- 公式编译成功但**运行时错误**:`#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`——这一类只看 `formula_errors[]` 看不到,必须扫单元格值。
|
||||
|
||||
`+formula-verify` 把两路信号合并成一份统一 JSON:一次调用聚合全表错误清单 + 编译失败清单 + 每类错误的定位与样本,AI 一眼就能定位修复,链路也能据 `status` 强制收敛到 `success`。
|
||||
|
||||
## 调用契约
|
||||
|
||||
最小调用形态:
|
||||
|
||||
| 入参 | 含义 |
|
||||
|---|---|
|
||||
| `--url` / `--spreadsheet-token` | 表格定位(XOR 二选一,必填) |
|
||||
| `--sheet-id` / `--sheet-name` | 限定子表(mutually exclusive;省略则扫全部可见子表) |
|
||||
| `--range` | 限定 A1 范围;省略则用各 sheet 的 `current_region` |
|
||||
| `--max-locations` | 每类错误样本上限,默认 20 |
|
||||
| `--exit-on-error` | `status='errors_found'` 时返回非 0 退出码(CI 网关用) |
|
||||
|
||||
返回核心字段:
|
||||
|
||||
- `status` ∈ `success` / `errors_found` / `partial`——**唯一可机读的健康度判据**。
|
||||
- `total_errors` / `total_formulas` / `scanned_cells`——本次扫描规模指标。
|
||||
- `has_more`——为 true 表示扫描被内部上限截断(详见后文「截断与续读」),未覆盖完整范围。
|
||||
- `error_summary[<错误类型>]`——每类错误的 `count` / `locations[]` / `samples[].{address,formula,depends_on}`。
|
||||
- `compile_errors[]`——合并最近一次写入留下的编译失败清单,与运行时错误并存时同时出现。
|
||||
- `warning_message`——仅在 `has_more=true` 时出现,告知调用方需要缩小 `--range` / 拆 `--sheet-id` 续读。
|
||||
|
||||
## 写入收尾收敛规则
|
||||
|
||||
任何批量公式 / 含公式列写入完成后调用 `+formula-verify` 直到 `status='success'` 才能交付。触发场景:
|
||||
|
||||
- `+cells-set` / `+cells-csv-set`
|
||||
- `+sandbox-import`
|
||||
- `+batch-update` 中含写入子操作
|
||||
- `+table-put`(任意列含公式时)
|
||||
- `+workbook-import`(导入的 xlsx 含公式时)
|
||||
|
||||
收敛规则:
|
||||
|
||||
1. `status='success'` → 通过;可以把链路标完成。
|
||||
2. `status='partial'` → 扫描被内部上限截断。先缩小 `--range` 或拆 `--sheet-id` 续扫,**不允许**把 `partial` 当作 `success`。
|
||||
3. `status='errors_found'` 且 `compile_errors[]` 非空 → **先解决编译失败**:根据 `compile_errors[].reason` 修正公式语法(飞书函数名 / 范围语法 / 引用样式),用 `+cells-set` 重写后再调一次 `+formula-verify`。
|
||||
4. `status='errors_found'` 且只剩运行时错误 → 按 `error_summary` 的 `samples[].formula` + `depends_on` 排查根因(零除?空值参与运算?引用越界?日期差写法?数组语义?),修复后重新自检。
|
||||
5. 同一处错误连续修复 3 次仍未通过 → 改用 `IFERROR` 包裹兜底,或退回纯值写入;不要在 `errors_found` 状态下扩展 `+cells-set --copy-to-range`、追加批量写入。
|
||||
|
||||
注意:
|
||||
|
||||
- 在 `status='errors_found'` 的状态下调用 `+cells-set --copy-to-range` 继续扩展会把错误复制放大。
|
||||
- "编译失败但运行时无报错"不是 zero-error(编译失败的单元格此刻是文本不是公式,源数据一变就再也算不出值)。
|
||||
- 跳过自检直接交付、靠肉眼读首末 5 行确认是不可靠的——表中段、隐藏行、合并区里的错误这样根本看不到。
|
||||
|
||||
## 截断与续读
|
||||
|
||||
后端有一个内部硬上限对总扫描单元格数做截断(不暴露给调用方),超过后立即返回 `has_more=true` + `warning_message`,`error_summary` / `compile_errors` 仅覆盖已扫描部分。处理路径:
|
||||
|
||||
- 把工作簿按 `--sheet-id` / `--sheet-name` 拆成多次调用。
|
||||
- 同 sheet 内按 `--range` 切片(如先 `A1:Z200` 再 `AA1:AZ200`),逐块自检。
|
||||
- 每块都跑到 `has_more=false` 且 `status='success'` 才算通过。
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
| 坑 | 应对 |
|
||||
|---|---|
|
||||
| 错误字符串本地化 | 后端按内部 `error_kind` / `compute_status` 字段识别错误类别,不走字符串匹配;调用方拿到的 7 类英文错误代码由后端统一规范输出,与 locale 无关。 |
|
||||
| `formatted_value` 可能隐藏错误 | 某些条件格式 / 自定义数字格式会把 `#DIV/0!` 显示成空白。后端直接读 cell `error_kind`,不依赖 `formatted_value`,绕开此类被遮蔽。 |
|
||||
| 把 `partial` 当 `success` | `partial` 仅表示**已扫描部分**无错误,剩余区域未知。必须续扫直到 `has_more=false` 且 `status='success'` 才能算通过。 |
|
||||
| 编译失败 vs 运行时错误 | 同一份报告里 `compile_errors[]` 与 `error_summary` 并存。语义层先解决 `compile_errors[]`、再做运行时自检。 |
|
||||
@@ -1,93 +0,0 @@
|
||||
# Lark Sheet History
|
||||
|
||||
## 概念回顾
|
||||
|
||||
每张飞书电子表格保留一串历史版本(`minor_histories`)。每个版本由 `history_version_id` 标识,并附带创建时间(`create_time`)、动作(`action`)与块修订信息(`all_block_revision`)。历史是**工作簿级**的(针对整张电子表格,不针对单个子表)。
|
||||
|
||||
回滚(revert)把电子表格的当前内容覆盖回某个历史版本——这是一个**写入 / 不可逆**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。
|
||||
|
||||
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果。若只是想拿**当前文档版本号(revision)**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读取历史版本、发起回滚、查询回滚状态。本 reference 覆盖 3 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version`) |
|
||||
| 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 |
|
||||
| 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 |
|
||||
|
||||
典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id` → `+history-revert-status --transaction-id <transaction_id>` 轮询直到成功或失败。
|
||||
|
||||
**注意事项(必须了解)**:
|
||||
- **回滚是写入 / 不可逆操作**:会用历史版本内容覆盖当前表格,发起前请确认目标 `history_version_id` 正确。
|
||||
- **回滚是异步的**:`+history-revert` 返回的是 `transaction_id`(受理标识),不代表回滚已完成;必须用 `+history-revert-status --transaction-id <transaction_id>` 确认最终结果。
|
||||
- **`history_version_id` 与 `transaction_id` 不是同一个**:`history_version_id` 用于 `+history-revert`(取自 `+history-list`);`transaction_id` 用于 `+history-revert-status`(取自 `+history-revert` 的输出)。
|
||||
- **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`(XOR),不需要子表选择器。
|
||||
- **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version` 与 `has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+history-list` | read | 历史版本 |
|
||||
| `+history-revert` | write | 历史版本 |
|
||||
| `+history-revert-status` | read | 历史版本 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+history-list`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--end-version` | int | optional | 分页查询的最大版本(倒序);首次查询省略,下一页传上一页返回的 next_end_version。 |
|
||||
|
||||
### `+history-revert`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--history-version-id` | string | required | 要回滚到的历史版本(取自 +history-list) |
|
||||
|
||||
### `+history-revert-status`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--transaction-id` | string | required | 异步回滚的受理标识(取自 +history-revert) |
|
||||
|
||||
## Examples
|
||||
|
||||
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`(XOR,二选一)。`+history-revert` 用 `--history-version-id`(取自 `+history-list`);`+history-revert-status` 用 `--transaction-id`(取自 `+history-revert` 的异步受理标识)。
|
||||
|
||||
### `+history-list`
|
||||
|
||||
```bash
|
||||
# 列出某张电子表格的最新一页历史版本
|
||||
lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx"
|
||||
|
||||
# 用原始 spreadsheet token 定位
|
||||
lark-cli sheets +history-list --spreadsheet-token "SHTxxxxxx"
|
||||
|
||||
# 翻到下一页:把上次响应里的 next_end_version 作为 --end-version 传入
|
||||
lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --end-version 12345
|
||||
```
|
||||
|
||||
### `+history-revert`
|
||||
|
||||
```bash
|
||||
# 回滚到指定历史版本(异步受理)
|
||||
lark-cli sheets +history-revert --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --history-version-id "<id-from-history-list>"
|
||||
```
|
||||
|
||||
### `+history-revert-status`
|
||||
|
||||
```bash
|
||||
# 查询某次回滚的当前状态(进行中 / 成功 / 失败)
|
||||
lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --transaction-id "<transaction-id-from-history-revert>"
|
||||
```
|
||||
@@ -32,10 +32,9 @@
|
||||
**常见配置错误(必须注意)**:
|
||||
- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误
|
||||
- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反
|
||||
- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`;"统计总额"→ `"sum"`;"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`。按用户意图选聚合方式,不要拿 `count` 顶替 `sum`
|
||||
- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`;"统计总额"→ `"sum"`;"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`。默认不要用 `count` 替代 `sum`
|
||||
- **参数长度限制**:如果透视表配置 JSON 过长(数据源范围跨越大量行列),可能导致工具调用失败。此时应先确认数据范围的精确边界,避免传入过大的 range
|
||||
- **落点不能覆盖任何已有数据(不只是 `--source` 范围)**:透视表创建后会向右下**展开**,展开区域哪怕只盖到一个已有单元格(即便已避开源数据),也会报「目标位置不能与数据源重叠」并产生 `#REF!`。创建前无法精确预知展开尺寸,故**强烈优先默认策略**(不传 `--target-sheet-id/-name` 与 `--target-position`/`--range`,后端自动新建空白子表),零覆盖风险;非要落到已有子表,必须挑一片足够大的纯空白区
|
||||
- **创建后必须校验(用 `info` 读取展开后的真实占用区域)**:创建后调用 `+pivot-list` 读 `info.error_state` 与 `info.content_range`/`page_range`——`error_state` 非 `None`(如 `Cover` 盖到其它内容 / `Shrink` 展不开)说明落点冲突,应删除后重建到空白区;`content_range`/`page_range` 是展开后**实际占用区域**,可用 `+csv-get` 抽查其边缘外有没有盖掉原有数据,确认结构正确
|
||||
- **创建后必须验证**:调用 `+pivot-list` 确认透视表结构正确
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -121,10 +120,6 @@ _创建/更新的透视表属性_
|
||||
lark-cli sheets +pivot-list --url "..." --sheet-id "$SID"
|
||||
```
|
||||
|
||||
> **返回值含 `info`(展开后的占用区域与状态)**:每个透视表对象除 `position` / `snapshot` 外,还返回 `info`,标明它在 sheet 上的平铺区域与状态——`info.page_range`(筛选/分页区 A1)、`info.content_range`(主体数据区 A1)、`info.span_range`(空表合并区 A1)、`info.error_state`(错误状态,如 `None`/`Cover`/`Shrink`/`Loading`)、`info.is_empty` / `info.is_hidden`、`info.row`/`info.col`(锚点)等。
|
||||
> **用途 1(判断改值还是改配置)**:当用户描述某个单元格要改动时,先 `+pivot-list` 拿到 `info`,判断该单元格是否落在 `page_range` / `content_range` 内——**落在区域内 = 属于透视表,应走 `+pivot-update` 改配置**(透视表单元格不能直接 `+cells-set` 改值);**落在区域外 = 普通单元格,正常 `+cells-set` 改值**。
|
||||
> **用途 2(创建后校验覆盖)**:建完透视表用 `info.error_state` 判断有没有冲突(非 `None` 即落点/展开区与已有数据重叠或展不开),用 `info.content_range`/`page_range` 拿到展开后真实占用区域再核对是否盖到原有数据。
|
||||
|
||||
### `+pivot-create`
|
||||
|
||||
> 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
|
||||
注意:
|
||||
|
||||
- **`--range` 两种语法别混**:`+cells-clear` / `+cells-{merge|unmerge}` / `+range-*` 用单元格 A1 矩形(如 `A2:A10`);`+rows-resize` / `+cols-resize` 用纯行 / 列区间(行 `2:10`、列 `A:C`),不要给 resize 传 `A2:A10`
|
||||
- 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`;"这列 / 整列"时使用 `J:J`。不要截断为局部矩形
|
||||
- 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作
|
||||
- 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测
|
||||
@@ -36,7 +35,7 @@
|
||||
2. **判定阈值**:当前列宽(用 `+sheet-info --include row_heights,col_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。
|
||||
3. **修复二选一**:
|
||||
- **扩列宽**:用 `+rows-resize / +cols-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值)
|
||||
- **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`;`cell_styles` 字段见 `lark-sheets-write-cells`),并用 `+rows-resize / +cols-resize` 调高对应行的行高
|
||||
- **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+rows-resize / +cols-resize` 调高对应行的行高
|
||||
4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。
|
||||
|
||||
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 列格式多样性预探(写公式 / 排序 / 筛选前必做)
|
||||
|
||||
> 本节给出"写公式 / 排序 / 筛选前先探清列格式多样性"的正确流程,是主 SKILL.md「飞书表格编辑准则」准则 3(读全再写)在 read_data 工具层的落地。
|
||||
> 对应 `lark-sheets-core-operations` 的 **R3 计算复现**——本节是 R3 在 read_data 工具层的具体落地。
|
||||
|
||||
对参与后续**计算 / 排序 / 筛选 / 公式提取**的列,**必须**先 sample **至少 50 行**(小表则全量),识别该列所有值类型变体后再设计公式 / 条件。只看前 10 行不够,因为下列差异通常潜伏在表尾或中段:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|
||||
|---------|----------------|---------|------|
|
||||
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) |
|
||||
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验。注意这与下文 `current_region` "遇表中部空行截断"不矛盾:`+table-get` 读的是子表物理 used range(飞书记录的已用矩形,含中间空行),`current_region` 是从锚点连通扩展、遇整行空行就断 |
|
||||
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验 |
|
||||
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 |
|
||||
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
注意:
|
||||
|
||||
- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前都必须先读 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
|
||||
- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
|
||||
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
**常见配置错误(必须注意)**:
|
||||
- **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引
|
||||
- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置
|
||||
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容(`+csv-get` / `+cells-get` 见 `lark-sheets-read-data`)
|
||||
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容
|
||||
- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`
|
||||
- **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用
|
||||
- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续
|
||||
@@ -129,7 +129,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--depth` | int | optional | 要取消的分组层级,默认 1(1=最外层,数字越大越内层) |
|
||||
| `--depth` | int | optional | 要取消的分组层级,默认 1(最外层) |
|
||||
| `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` |
|
||||
|
||||
### `+dim-move`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 飞书表格样式与配色规范
|
||||
|
||||
> **本文定位**:飞书表格"正确视觉输出"的取值标准与美化决策流——配色、表头、对齐、数值格式、斑马纹、列宽行高、图表展示,以及新增 / 继承 / 美化已有区域三类场景的做法。
|
||||
> **边界**:本文只讲"样式长什么样、怎么决策";**怎么调用工具写入样式**(`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `lark-sheets-write-cells` / `lark-sheets-range-operations` / `lark-sheets-batch-update`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `lark-sheets-conditional-format`。本文不含 shortcut,通用编辑准则见主 SKILL.md「飞书表格编辑准则」。
|
||||
> **边界**:本文只讲"样式长什么样、怎么决策";**怎么调用工具写入样式**(`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `lark-sheets-write-cells` / `lark-sheets-range-operations` / `lark-sheets-batch-update`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `lark-sheets-conditional-format`。本文不含 shortcut,铁律见 `lark-sheets-core-operations`。
|
||||
|
||||
## 最高优先级原则
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
- 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。
|
||||
- **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。
|
||||
- 具体采样与复制流程见下方「场景二:从已有区域继承美化」。
|
||||
- **列宽 / 行高调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值):
|
||||
- **列宽调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值):
|
||||
- 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。
|
||||
- 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。
|
||||
- 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。
|
||||
@@ -82,7 +82,7 @@
|
||||
- 包含必要元素:标题、图例、数据标签、坐标轴标题。
|
||||
- 调整至合适大小,避免数据和标签过多堆叠。
|
||||
- **图表放置防重叠**:新增图表前须计算放置区域,避免与已有图表重叠。具体步骤:
|
||||
1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`col` 是列字母如 "A"/"B"、`row` 是 1-based 行号;以 `+chart-list` 实际返回字段为准)、`offset`(锚点内偏移:`row_offset`、`col_offset`,单位像素)以及 `size`(`width`、`height`,单位像素)。
|
||||
1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`row` 行索引、`col` 列索引如 "A"/"B")、`offset`(锚点内偏移:`row_offset`、`col_offset`,单位像素)以及 `size`(`width`、`height`,单位像素)。
|
||||
2. 获取工作表的行高和列宽信息(像素)。
|
||||
3. 根据每个图表的锚点 `position.row`/`position.col` + 偏移 `offset.row_offset`/`offset.col_offset` + 尺寸 `size.width`/`size.height`,结合行高列宽,计算出每个已有图表覆盖的像素矩形区域 `(x_min, y_min, x_max, y_max)`。
|
||||
4. 为新图表选定大小后,候选放置位置应避开所有已有矩形区域;若存在重叠则向下或向右偏移,直至找到无冲突位置。
|
||||
|
||||
@@ -15,12 +15,7 @@
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看工作簿结构 | `+workbook-info` | 获取子表列表、名称、行列数、冻结位置等元数据 |
|
||||
| 获取当前 revision | `+revision-get` | 获取当前文档 revision(版本号),可作为 recover / undo / changeset 复核的版本锚点 |
|
||||
| 新建工作簿(可预填数据) | `+workbook-create` | 从内存数据建一张新表(`--values` / `--sheets` typed) |
|
||||
| 导入本地文件为新表 | `+workbook-import` | 把本地 `.xlsx` / `.xls` / `.csv` 导入为新的飞书电子表格 |
|
||||
| 导出工作簿到本地 | `+workbook-export` | 导出为本地 `.xlsx`(整簿)或单子表 `.csv` |
|
||||
| 变更工作簿结构 | `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | 新建/删除/移动/重命名/复制/隐藏子表、修改标签颜色 |
|
||||
| 切换子表网格线显隐 | `+sheet-show-gridline` / `+sheet-hide-gridline` | 显示 / 隐藏单个子表的网格线 |
|
||||
|
||||
注意:
|
||||
|
||||
@@ -38,7 +33,6 @@
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+workbook-info` | read | 工作簿 |
|
||||
| `+revision-get` | read | 工作簿 |
|
||||
| `+sheet-create` | write | 工作簿 |
|
||||
| `+sheet-delete` | high-risk-write | 工作簿 |
|
||||
| `+sheet-rename` | write | 工作簿 |
|
||||
@@ -61,12 +55,6 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
_仅含公共 / 系统 flag。_
|
||||
|
||||
### `+revision-get`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
_仅含公共 / 系统 flag。_
|
||||
|
||||
### `+sheet-create`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
@@ -77,7 +65,6 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| `--index` | int | optional | 插入位置(0-based);省略时附加到末尾 |
|
||||
| `--row-count` | int | optional | 初始行数(默认 200,上限 50000) |
|
||||
| `--col-count` | int | optional | 初始列数(默认 20,上限 200) |
|
||||
| `--type` | string | optional | 新子表类型:sheet(电子表格)\| bitable(多维表格);默认 sheet。bitable 只建空表,内容编辑改用 lark-base 命令 |
|
||||
|
||||
### `+sheet-delete`
|
||||
|
||||
@@ -100,7 +87,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--index` | int | required | 目标位置(0-based) |
|
||||
| `--source-index` | int | optional | 源位置(0-based);standalone 调用时可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生。但在 `+batch-update` 内不可省(须显式传)——batch 中途无法发起结构查询自动派生 |
|
||||
| `--source-index` | int | optional | 源位置(0-based);可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生 |
|
||||
|
||||
### `+sheet-copy`
|
||||
|
||||
@@ -151,7 +138,7 @@ _系统:`--dry-run`_
|
||||
| --- | --- | --- | --- |
|
||||
| `--title` | string | required | 新 spreadsheet 标题 |
|
||||
| `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 |
|
||||
| `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别(日期 / 数字会落成文本,需类型保真改用 --sheets),走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
|
||||
| `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
|
||||
| `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 `{"sheets":[...]}`,每个数组项是一张子表 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` —— `name` 与外层 `sheets` 数组都不可省。Agents 用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 把 DataFrame 转成一项再包 `{"sheets":[...]}`。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
|
||||
| `--styles` | string + File + Stdin(复合 JSON) | optional | 建表时同时写入的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。完整 cell_styles 字段结构跑 `+workbook-create --print-schema --flag-name styles`。 |
|
||||
|
||||
@@ -197,7 +184,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
@@ -208,17 +195,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
### `+workbook-info`
|
||||
|
||||
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `index` / `resource_type` / `row_count` / `column_count` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
|
||||
|
||||
> **子表类型 `resource_type`**:`sheet`(普通网格子表)/ `bitable`(内嵌的多维表格子表)/ `#UNSUPPORTED_TYPE`(其它暂不支持的嵌入子表)。
|
||||
> - 网格类操作(读写单元格 / 区域 / 样式 / CSV / 筛选 / 透视 / 图表等)**仅适用于 `sheet`**。对 `bitable` / `#UNSUPPORTED_TYPE` 子表执行网格操作会被直接拒绝并返回明确报错,不再静默出错。
|
||||
> - 要操作 `bitable` 子表里的数据:该子表条目会附带 `bitable_app_token` + `bitable_table_id` 两个字段,直接用多维表格命令操作,例如 `lark-cli base +record-list --base-token <bitable_app_token> --table-id <bitable_table_id>`(记录增删改查、字段、视图等整套 `lark-cli base` 命令均可用)。不要走 sheets 网格命令。
|
||||
> - `bitable` / `#UNSUPPORTED_TYPE` 子表条目**只含** `sheet_id` / `sheet_name` / `index` / `resource_type`(bitable 另加上述两个 token)以及 `is_hidden` / `tab_color`;**不输出** `row_count` / `column_count` / `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count` / `frozen_*` 等网格指标(对非网格子表无意义)。
|
||||
> - tab 管理类操作(`+sheet-rename` / `+sheet-move` / `+sheet-delete` / `+sheet-hide` 等)对任意 `resource_type` 的子表都合法,不受此限制。
|
||||
|
||||
### `+revision-get`
|
||||
|
||||
输出契约:返回单个 `revision` 字段,即当前文档版本号。它是 recover / undo / `+changeset-get` 的版本锚点:如果刚执行过一次读写操作,也可以直接复用那次响应里的 `revision`;当只想单独取当前版本号、且不需要其它结构信息时,用 `+revision-get` 最直接。
|
||||
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
|
||||
|
||||
### `+workbook-create`
|
||||
|
||||
@@ -364,17 +341,8 @@ lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--title "汇总" --index 0
|
||||
```
|
||||
|
||||
新建一张**多维表格(bitable)子表**:加 `--type bitable`(默认 `sheet`,即普通电子表格子表)。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--title "任务表" --type bitable
|
||||
```
|
||||
|
||||
> 💡 `+sheet-create` 只建一张**空子表**。要在已有工作簿里建子表并一步写入 typed 数据和/或样式,用 `+table-put`(payload 里命名的子表缺则自动新建)配合它的 `--sheets` / `--styles`,省掉先建表再 `+cells-set` / `+cells-set-style` 的二次往返。
|
||||
|
||||
> 💡 `--type bitable` 只建一张**空的多维表格子表**(默认表 + 网格视图 + 默认字段)。它的内容编辑(字段、记录、视图)走 `lark-cli base`:先用 `+workbook-info` 拿到该子表的 `bitable_app_token` + `bitable_table_id`,再用 `lark-cli base +record-list` / `+record-create` 等操作;sheets 侧的网格类命令(`+cells-get` / `+cells-set` 等)对 bitable 子表会被拒。
|
||||
|
||||
### `+sheet-delete`
|
||||
|
||||
> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。
|
||||
2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。
|
||||
3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。
|
||||
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条准则:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
|
||||
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条铁律:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
|
||||
|
||||
## 新增列 / 新增行的样式继承(防止视觉风格不一致)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
**完整继承清单**(写新列 / 新行时 cells 数组必须同时携带):
|
||||
|
||||
1. `cell_styles.font_family` / `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`(字体名称 / 字号 / 粗细 / 颜色 / 斜体等)
|
||||
1. `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`(字号 / 粗细 / 颜色 / 斜体等)
|
||||
2. `cell_styles.horizontal_alignment` / `cell_styles.vertical_alignment`(H-Align / V-Align)—— 漏继承会导致新列对齐与原列不一致(常见)
|
||||
3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱
|
||||
4. `cell_styles.background_color`(背景色)
|
||||
@@ -43,8 +43,6 @@
|
||||
|
||||
**典型反例**:长数字列(如审批单号、流水号)未设 `number_format`,飞书显示为 `1.23E+15`,用户复制出来已经丢失精度。
|
||||
|
||||
> **数字还是文本,按"数据本质是量值还是标识符"二选一 —— 不看当下要不要计算**:金额 / 百分比 / 比率 / 计数 / 度量这类**本质是量值**的数据,一律以**数字类型**写入(百分比存小数 `0.54` 配 `number_format:"0%"`),**不要**设 `@` 文本格式。**这与"用户当下是否要排序 / 求和"无关**——数据类型由数据本质决定、不由当下用途决定:表格数据几乎总会被后续排序 / 图表 / 二次计算复用,`"54%"` 文本与数值列混排本就破坏一致性,且数字 + `number_format` 显示效果与文本**完全相同**,没有任何理由选文本。**最常见的误判就是"这只是 leaderboard / 报表 / 看板展示,又不用算,写成 `54%` 字符串就行"——这是错的,展示用途不改变"百分比是数值"的事实。**(`+table-put` 用 `dtypes` 声明 `int64` / `float64`;版式 `+table-put` 装不下时用 `+cells-set` 传数字 + `number_format`;都别在本地拼成带 `$` / `%` 的字符串走 `+csv-put`。)反过来,编号 `001`、规格 `3-1`、身份证 / 电话 / 单据号等**本质是标识符 / 标签**、要原样保留不被飞书自动解释的内容(否则 `001`→`1`、`3-1`→日期、长号→科学计数),才以**字符串类型**写入(`dtypes` 设 `object`)并把 `number_format` 设为 `"@"`(文本格式),字面保真。
|
||||
|
||||
## 使用场景
|
||||
|
||||
写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut,按数据来源 + 内容形态选:
|
||||
@@ -52,24 +50,23 @@
|
||||
| 场景 | 用这个 shortcut | 原因 |
|
||||
|------|----------------|------|
|
||||
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
|
||||
| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书,要类型保真(来源不限:DataFrame、Counter、dict、list 都算) | `+table-put` | typed 协议(外层 `{"sheets":[{"name":"…","columns":[...],"data":[[...]],"dtypes":{...},"formats":{...}}]}`,**只有这四件套字段**):`dtypes` 用 pandas dtype 串声明列类型(`int64` / `float64` / `datetime64[ns]` / `bool` / `object`),`formats` 给每列展示格式(千分位 / 百分比 / 日期)。**date 落真日期、金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写 |
|
||||
| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书,要类型保真(来源不限:DataFrame、Counter、dict、list 都算) | `+table-put` | typed 协议(外层 `{"sheets":[{"name":"…","columns":[...],"data":[[...]],"dtypes":{...},"formats":{...}}]}`,**只有这四件套字段**):`dtypes` 用 pandas dtype 串声明列类型(`int64` / `float64` / `datetime64[ns]` / `bool` / `object`),`formats` 给每列展示格式(千分位 / 百分比 / 日期)。**date 落真日期、金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写。**只要列有数值语义就走这里**,不要在本地把数字拼成带 `$` / `%` 的字符串再走 `+csv-put` |
|
||||
| 写入含样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整富字段的 shortcut(公式 `+csv-put` 也能写) |
|
||||
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 |
|
||||
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
|
||||
| 在**已有区域**局部补表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
|
||||
| **新建子表 / 整表成套美化**(哪怕全是纯文本) | `+table-put --sheets … --styles …` 一步带值 + 全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并;payload 里不存在的 sheet 名自动建子表) | `--styles` 与列是否 typed 无关,纯文本同样适用;比「写值 + 多次刷样式」少好几次调用 |
|
||||
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
|
||||
|
||||
**选命令按内容形态分流(不设"默认首选")**:① 列有数值语义(金额 / 百分比 / 日期 / 计数)→ `+table-put`(`dtypes` 声明类型 + `formats` 设展示格式),版式装不下时 → `+cells-set` 传数字 + `number_format`;② 要样式 / 批注 / 图片 / 富文本 → `+cells-set`;③ **仅**全文本、无数值语义的内容平铺 → `+csv-put`(入参最短)。判据详见上方「数字还是文本」。
|
||||
**优先级**:常规批量写入(纯值或公式)优先 `+csv-put`(最短入参,直接传 CSV 文本);含样式/批注/图片才用 `+cells-set`。⚠️ 这里"纯值"特指**已是文本、无需保留数值语义**的内容;只要列里是金额 / 百分比 / 日期 / 计数等有数值语义的数据,应优先 `+table-put`(用 typed 协议的 `dtypes` 声明列类型 + `formats` 设展示格式),而不是 `+csv-put`。
|
||||
|
||||
⚠️ `+csv-put` 可写值或公式:以 `=` 开头的单元格会被当作公式计算(读回时 `formula` 字段保留、`value` 为计算结果)。**公式内部含逗号 / 引号 / 换行时必须按 RFC 4180 转义**——含逗号的字段整格用双引号包裹、字段内部的引号再翻倍:如 `=COUNTIF(D5:D22,"及格")` 必须写成 `"=COUNTIF(D5:D22,""及格"")"`(外层双引号包裹整格,内部 `"及格"` 的引号翻倍成 `""及格""`)。漏转义会被 CSV 解析器按逗号拆列、整块写入区域错位(如本该 `G4:H6` 错成 `G4:K4`),详见下方 `+csv-put` 示例。**因此含逗号 / 引号 / 换行的公式优先改用 `+cells-set`(JSON 二维数组)写入——`cells[r][c].formula` 字段直接放公式串,零 CSV 转义负担,从根上避免拆列错位**(`+table-put` 的 typed 协议只接受 `columns / data / dtypes / formats` 四件套、没有 `formula` 字段,公式写入只能走 `+cells-set` / `+csv-put`)。此外 `+csv-put` **不会**携带样式/批注/图片,也无法把 `=` 开头的内容当字面量文本写入;需要样式/批注/图片用 `+cells-set`(或"写值 + 补样式"两步法)。
|
||||
|
||||
⚠️ **`+csv-put` 会把数值落成文本**:把金额 / 百分比 / 计数等在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格就是**文本**——丢失排序 / 求和 / 图表能力,且与数值列混排无法参与计算。数值该怎么写、何时 `+table-put`、版式装不下时何时退 `+cells-set` 传数字 + `number_format`,判据与分流见上方「数字还是文本」;核心一句:**准备把数字 format 成字符串再写时就是走错了路,数值一律以数字写入 + `number_format` 控制显示。**
|
||||
⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**:金额 / 百分比 / 市值 / 计数等列,若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put --sheets` 完整 payload(外层一定要带 `{"sheets":[...]}`、列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`),数值列用 pandas dtype 串如 `dtypes:{"价格":"float64"}`(百分比同样存小数 `0.305`),并配 `formats:{"价格":"$#,##0.00","完成率":"0.0%"}` 做展示格式,**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,几乎总该用 `+table-put` 而非 `+csv-put`**。
|
||||
|
||||
⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
|
||||
|
||||
## `+cells-set` 写入要点(常用模式 / 公式 / 样式)
|
||||
|
||||
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。
|
||||
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与铁律;选哪个 shortcut 见上方「使用场景」。
|
||||
|
||||
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。
|
||||
|
||||
@@ -83,14 +80,12 @@
|
||||
- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range`
|
||||
- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域
|
||||
|
||||
⚠️ **模板 `--range` 从数据行起算、别把表头圈进去**:`--copy-to-range` 会把 `--range` 模板按目标区尺寸周期性平铺,模板里若含了表头行,表头会每隔几行重复铺进数据区。整列填充时模板只取一格数据样式(如 `H2`),不要取成 `H1:H2`。
|
||||
|
||||
⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。
|
||||
|
||||
💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。
|
||||
|
||||
💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步:
|
||||
1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简。⚠️ 这里"不带样式"指暂不带 `cell_styles`,**不是**降级用 `+csv-put` 铺文本——数值列(百分比 / 金额 / 计数)仍必须以数字写入(百分比传 `0.44`):样式能后补,数据类型不能后补(见上方「数字还是文本」)。
|
||||
1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简
|
||||
2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `--copy-to-range` 将样式扩展到整列 / 整行 / 整个区域(`--copy-to-range` 会复制值、公式和样式,所以模板单元格应已包含正确的值)
|
||||
|
||||
示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框:
|
||||
@@ -232,7 +227,7 @@ lark-cli sheets +dropdown-set \
|
||||
|
||||
> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。
|
||||
>
|
||||
> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`,写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**:sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上。
|
||||
> ⚠️ **sheet 前缀里的表名一律「裸写」,不要加引号**——这条对所有带 sheet 前缀的 range 入参通用(`--source-range`、`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` 的 `--ranges` 等)。即使表名含点或空格(如 `2025.9`、`一月份 `),也直接写 `2025.9!A1`;**不要**按电子表格习惯写成 `'2025.9'!A1`——引号会被当成表名的一部分,导致 `sheet "'2025.9'" not found`。
|
||||
|
||||
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
|
||||
|
||||
@@ -270,7 +265,6 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| `--range` | string | required | 目标范围(A1 格式,如 `A1:B2`) |
|
||||
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) |
|
||||
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) |
|
||||
| `--font-family` | string | optional | 字体名称(如 `Arial`、`微软雅黑`) |
|
||||
| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) |
|
||||
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) |
|
||||
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) |
|
||||
@@ -336,7 +330,7 @@ _【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]]
|
||||
- `value` (oneOf?) — 静态单元格值(文本、数字、布尔)
|
||||
- `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')
|
||||
- `note` (string?) — 单元格批注/备注
|
||||
- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_family?: string, font_size?: number, font_weight?: enum, font_style?: enum, …共 11 项 }
|
||||
- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_size?: number, font_weight?: enum, font_style?: enum, font_line?: enum, …共 10 项 }
|
||||
- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { top?: object, bottom?: object, left?: object, right?: object }
|
||||
- `rich_text` (array<object>?) — 富文本内容 each: { type: enum, text: string, style?: object, link?: string, mention_token?: string, …共 17 项 }
|
||||
- `multiple_values` (array<object>?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string }
|
||||
@@ -379,7 +373,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
|
||||
@@ -20,6 +20,7 @@ metadata:
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 使用 AnyGen md 资产驱动的 SVGlide 本地工作台 | 先读 `references/anygen-svg/README.md` 和 `next` 返回的 AnyGen prompt paths,再按 adapter 写入 run-dir 产物;本地校验/预览/修复,不发布飞书 | [`references/anygen-svg/README.md`](references/anygen-svg/README.md)、[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.md)、`slides +create-svglide` |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
@@ -79,11 +80,21 @@ lark-cli auth login --domain slides
|
||||
- 编辑:[`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)
|
||||
- AnyGen md 资产驱动的 SVGlide 本地工作台:[`references/anygen-svg/README.md`](references/anygen-svg/README.md)、[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
执行 `slides +create-svglide` 链路时,AnyGen md 资产优先于本地 adapter:
|
||||
|
||||
当用户明确要求“生成 SVGlide / 创建 SVGlide PPT / 执行 SVGlide 链路 / 用 SVGlide 做一份 PPT”时,视为已授权进入执行流程。不要因为任务包含创作、构思或视觉设计就再触发 `superpowers:brainstorming` 的 approval gate。只有缺少必要输入、目标互相矛盾、或会产生外部不可逆副作用时,才暂停询问。
|
||||
|
||||
1. 读取 `references/anygen-svg/README.md`。
|
||||
2. 读取 `next` 返回的 `prompt_paths`。
|
||||
3. 读取 `adapter_paths` 理解本地 run-dir 和 action 约束。
|
||||
4. 只用 Go action 推进状态、校验结构、生成 preview;不要把 Go fallback author 当成 AnyGen 生成主路径。
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
@@ -247,6 +258,7 @@ 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) | 以 AnyGen md 资产为生成语义,创建和管理本地 SVGlide SVG run-dir,不发布到飞书,不调用 slide_engine |
|
||||
| [`+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 的多页大改,不新建链接 |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user