Compare commits

...

10 Commits

Author SHA1 Message Date
songtianyi.theo
2f15da6e95 中文化 SVGlide 视觉规则文档
将 visual recipe 和 aesthetic review 两个执行入口改为中文说明,保留字段名、枚举、命令和 JSON 契约。
2026-06-10 19:28:59 +08:00
songtianyi.theo
31aa9db726 完善 SVGlide 视觉生成与预检门禁
新增 style presets、visual recipe 和 aesthetic review gate,并接入 lark-slides skill 执行链路。

扩展 create-svg 图片处理、协议说明与 svg_preflight 校验,补充对应测试。
2026-06-10 19:23:25 +08:00
songtianyi.theo
3bbf823ce9 docs: harden svglide workflow guidance 2026-06-08 02:37:38 +08:00
songtianyi.theo
e43a57ce14 docs: strengthen svglide generation guidance 2026-06-08 00:42:25 +08:00
songtianyi.theo
edf7ad81dd docs: add svglide deck density planning 2026-06-05 15:20:24 +08:00
songtianyi.theo
d98ef05dc7 feat: add svglide create-svg shortcut 2026-06-05 14:12:09 +08:00
MaxHuang22
24ce3ec151 feat: add --json flag as no-op alias for --format json (#1104)
* feat(api): add --json flag as no-op alias for --format json

* feat(service): add --json flag as no-op alias for --format json

* feat(shortcut): add --json flag as no-op alias for --format json

Skip registration when a custom --json flag already exists on the
command (e.g. base shortcuts use --json for body input).

Change-Id: If66236cadeea7fa81811061cce775deff51b92ce
2026-06-03 13:58:14 +08:00
dc-bytedance
2bbab4d851 feat: validate credentials after config init (#1151)
* refactor: extract FetchTAT sharing the TAT-rejection classifier

doResolveTAT minted the tenant access token inline. Extract the HTTP call
into FetchTAT(ctx, httpClient, brand, appID, appSecret) so callers that
already hold plaintext credentials — notably the post-config-init probe —
can validate them without a second keychain round-trip.

FetchTAT routes a non-zero TAT body code through the same
classifyTATResponseCode the credential layer already uses, so a rejection is
the canonical CategoryConfig / SubtypeInvalidClient (10003 / 10014) typed
error — identical to what every token-resolving command returns. Transport,
HTTP-status and JSON-parse failures stay raw (untyped) so callers can use
errs.IsTyped to separate a deterministic credential rejection from upstream
noise. doResolveTAT now delegates to FetchTAT; observable behavior unchanged.

* feat: validate credentials after config init

After config init saves the App ID / App Secret, fire a best-effort probe:
mint a tenant access token with the just-saved credentials, then POST the
application probe endpoint. When the credentials are deterministically
rejected, FetchTAT returns a typed errs.* error and runProbe propagates it,
so config init exits non-zero with the canonical ConfigError / invalid_client
envelope (the same one every other command shows for the same bad creds)
instead of letting the user discover the mistake on a later request.

Ambiguous failures (transport, HTTP non-200, JSON parse, timeout,
http-client init) come back untyped and are swallowed (errs.IsTyped is the
discriminator), so a valid configuration is never blocked by upstream noise.
The probe is wired into all four init paths and skipped when the user reused
an existing secret. The saved config is not rolled back on rejection: stdout
still records what was saved, stderr carries the typed error envelope.
2026-06-03 11:44:04 +08:00
evandance
98173ae5a9 feat(drive): emit typed error envelopes across the drive domain (#1205)
Drive-domain errors now leave the CLI as typed, machine-branchable
envelopes — a stable `type` plus `subtype` and named fields (param,
params, retryable, log_id, hint) — so scripts and AI agents can branch on
structure and act on a recovery hint instead of parsing prose.

Changes:
- Every error produced in the drive domain — validation, file I/O, and the
  failures returned from its Lark API calls — is emitted as a typed errs.*
  error; the exit code is derived from the error category. Drive's API calls
  now go through a shared typed classifier, so failures carry subtype,
  troubleshooter, a recovery hint, and the request's log_id whether the
  server returns it in the response body or the x-tt-logid header; an
  already-typed network/auth error is never downgraded into a generic API
  error.
- Known API conditions (resource conflict, cross-tenant, cross-brand, ...)
  carry a recovery hint keyed by their error class; a command can refine
  that hint with command-specific guidance.
- Batch partial failures (+push / +pull / +sync, where some items succeed
  and some fail) now report an honest ok:false multi-status result on
  stdout — the summary and every per-item outcome stay machine-readable —
  and exit non-zero, instead of a misleading ok:true success envelope.
- Duplicate rel_path conflicts report each colliding path as a structured
  params entry (RFC 7807 invalid-params style).
- Static guards lock the drive path so legacy error construction — direct
  envelopes or the auto-classifying API helpers — cannot be reintroduced,
  making drive the template for the remaining domains.

Output changes worth noting for consumers:
- Error envelopes now carry typed type/subtype and named fields; exit
  codes follow the error category (malformed or incomplete API responses
  are reported as internal errors rather than generic API errors).
- Batch partial failures (+push / +pull / +sync) emit an ok:false result
  envelope on stdout (summary + per-item items[]) and exit non-zero; the
  per-item results stay on stdout rather than in a stderr error envelope.

Errors surfaced through shared cross-domain helpers (scope precheck, media
import upload, metadata lookup, save-path resolution) are not yet typed;
they migrate with the shared layer in a follow-up change.
2026-06-03 10:27:15 +08:00
zhangheng023
c8e205eed2 fix: recover toUpdate skills empty fallback (#1233) 2026-06-02 23:26:16 +08:00
90 changed files with 10136 additions and 916 deletions

View File

@@ -65,10 +65,23 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -94,6 +107,23 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are

View File

@@ -90,6 +90,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")

View File

@@ -718,3 +718,23 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -341,6 +341,9 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
return err
}
return nil
}
@@ -380,6 +383,9 @@ func configInitRun(opts *ConfigInitOptions) error {
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
@@ -419,6 +425,11 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil
}
@@ -507,5 +518,10 @@ func configInitRun(opts *ConfigInitOptions) error {
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
return err
}
}
return nil
}

91
cmd/config/init_probe.go Normal file
View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// probeTimeout is the total wall-clock budget for the credential probe step
// (covering both TAT acquisition and the subsequent probe request).
const probeTimeout = 3 * time.Second
// runProbe runs a best-effort credential validation after config init has
// persisted the App ID and App Secret. It returns a non-nil error only for a
// deterministic credential-rejection signal; every other outcome returns nil
// so that valid configurations and transient/upstream noise never block the
// command.
//
// The function performs up to two HTTP calls in series, bounded by
// probeTimeout:
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the server deterministically rejected the credentials — a
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// so the root dispatcher renders the canonical envelope and `config init`
// exits non-zero — identical to how every other token-resolving command
// reports the same bad credentials. Ambiguous failures (transport errors,
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// errors and are swallowed (return nil), so valid configurations are never
// disturbed by upstream noise. errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
if factory == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
if err != nil {
// A typed error from FetchTAT is a deterministic credential rejection
// (classifyTATResponseCode). Propagate it so config init exits with the
// same envelope the rest of the CLI uses for bad credentials. Untyped
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
// silent and let the command succeed.
if errs.IsTyped(err) {
return err
}
return nil
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}

View File

@@ -0,0 +1,288 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
probeReq *http.Request
probeBody string
}
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
f.probeCalls++
f.probeReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
f.probeBody = string(b)
}
if f.probeHandler == nil {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// fakeFactory builds a test Factory whose HttpClient is overridden to use
// the caller-supplied RoundTripper.
//
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
// guidance). The HttpClient is then swapped to our stub so we can drive
// exact HTTP responses for the probe. Config-dir isolation is set up via
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
// touch lands in a temp dir rather than the developer's real config.
//
// The returned buffer is the Factory's stderr. runProbe never writes to
// stderr (it propagates a typed error or stays silent), so every test asserts
// this buffer stays empty as an invariant.
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
}
return f, errBuf
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// the expected upstream code. This is the same typed error every other
// token-resolving command returns for the same bad credentials, and nothing is
// written to stderr (the root dispatcher renders the envelope).
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
// written to stderr. Used for every ambiguous (non-credential) outcome.
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err != nil {
t.Errorf("expected nil (silent), got error: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("expected no stderr output, got: %q", errBuf.String())
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
}
// Any non-zero body code is a deterministic rejection and propagates (typed).
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
// rejection) → silent, exit 0.
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
for _, code := range []int{401, 403, 500} {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(code, `nope`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
}
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down")
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
rt := &fakeRT{
probeHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(500, `server error`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if rt.probeReq.Method != http.MethodPost {
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
}
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
t.Errorf("probe URL = %s", got)
}
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
t.Errorf("Authorization = %q, want Bearer t-ok", got)
}
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
t.Errorf("probe body missing from field: %s", rt.probeBody)
}
}
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
}
}
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
}
// A timeout is an ambiguous failure (context deadline → untyped), so it
// must stay silent and not block.
assertSilent(t, err, errBuf)
}

View File

@@ -241,6 +241,13 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return typedExit
}
// Partial-failure (batch / multi-status): the ok:false result envelope is
// already on stdout; set the exit code and write nothing to stderr.
var pfErr *output.PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)

View File

@@ -180,6 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {

View File

@@ -765,3 +765,22 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if captured == nil {
t.Fatal("expected runF to be called")
}
}

View File

@@ -155,7 +155,30 @@ caller scripts.
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
belongs in a typed `*errs.XxxError`.
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
### Partial failure (batch / multi-status)
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
many items can finish in a third state, neither full success nor a single
error: some items succeeded and some failed. Its primary output is the
per-item result, so it does **not** belong in a `stderr` error envelope.
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
1. writes the full result to **stdout** as an `ok:false` envelope — the
summary and every per-item outcome (succeeded *and* failed) stay
machine-readable, exactly as a successful `Out(...)` would carry them,
but with `ok` honestly reporting failure; and
2. returns `*output.PartialFailureError`, a typed exit signal the
dispatcher maps to a non-zero exit code while writing nothing further
to `stderr`.
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
*result*, reported on stdout, that also failed. Consumers branch on
`ok == false` and then read `data.summary` / `data.items[]`.
## Consumers

View File

@@ -12,7 +12,8 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
)
// CategoryAuthentication subtypes

View File

@@ -61,8 +61,22 @@ type TypedError interface {
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Cause error `json:"-"`
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"`
}
// InvalidParam is one structured validation diagnostic: the parameter that
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
// item (RFC 7807 §3.1 extension members).
//
// The wire key on ValidationError is "params" rather than "invalid_params"
// because the enclosing envelope already carries type:"validation", so the
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
// InvalidParam prefix because, at package level, the name must self-describe.
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
@@ -122,6 +136,11 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
return e
}
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
e.Params = append(e.Params, params...)
return e
}
func (e *ValidationError) WithCause(cause error) *ValidationError {
e.Cause = cause
return e

View File

@@ -558,6 +558,71 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
})
}
// TestValidationError_WithParams covers the structured-validation extension:
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
// and the wire shape nests {name, reason} under "params" (omitted when empty).
func TestValidationError_WithParams(t *testing.T) {
t.Run("appends and exposes fields", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
if len(e.Params) != 1 {
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
}
if e.Params[0].Name != "a.md" {
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
}
if e.Params[0].Reason != "duplicate" {
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
}
})
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
if returned != e {
t.Errorf("WithParams returned different pointer; want same as receiver")
}
e.WithParams(
errs.InvalidParam{Name: "b.md", Reason: "dup"},
errs.InvalidParam{Name: "c.md", Reason: "dup"},
)
if len(e.Params) != 3 {
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
}
})
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParam("--rel-path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
got := string(b)
for _, want := range []string{
`"type":"validation"`,
`"param":"--rel-path"`,
`"params":[{"name":"a.md","reason":"duplicate"}]`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %s", want, got)
}
}
})
t.Run("empty Params omitted from wire", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(b), `"params"`) {
t.Errorf("empty Params should be omitted from wire; got %s", b)
}
})
}
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
t.Run("WithMissingScopes clones input", func(t *testing.T) {
scopes := []string{"docx:document", "im:message:send"}

View File

@@ -4,9 +4,7 @@
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -166,42 +164,9 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil {
return nil, err
}
ep := core.ResolveEndpoints(acct.Brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": acct.AppID,
"app_secret": acct.AppSecret,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
}
return &TokenResult{Token: result.TenantAccessToken}, nil
return &TokenResult{Token: token}, nil
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/larksuite/cli/internal/core"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
// given credentials. It does not read configuration or keychain, so callers
// that already hold plaintext credentials (e.g. the post-`config init` probe)
// can validate them without a second keychain round-trip.
//
// A non-zero TAT response code means the server inspected the payload and
// rejected the credentials; FetchTAT returns the canonical typed error from
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// every token-resolving command) produces, so callers see one consistent
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
// credential rejection apart from upstream/transport noise.
//
// The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
})
if err != nil {
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
}
return result.TenantAccessToken, nil
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// stubRoundTripper lets us assert request shape and return canned responses.
type stubRoundTripper struct {
gotReq *http.Request
gotBody string
respCode int
respBody string
err error
}
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
s.gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
s.gotBody = string(b)
}
if s.err != nil {
return nil, s.err
}
return &http.Response{
StatusCode: s.respCode,
Body: io.NopCloser(strings.NewReader(s.respBody)),
Header: make(http.Header),
}, nil
}
func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{
respCode: 200,
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("request body missing credentials: %s", rt.gotBody)
}
}
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns.
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 10003")
}
if token != "" {
t.Errorf("token = %q, want empty", token)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
}
// 10014 ("app secret invalid") — the most common real-world rejection (real
// app_id + wrong secret) — is globally mapped in codemeta to
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
}
}
// Any non-zero body code is a deterministic server-side rejection, so it
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// caller still surfaces it rather than silently swallowing.
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 99999")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
}
}
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
// silent.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatalf("HTTP %d: expected error", code)
}
if errs.IsTyped(err) {
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
}
}
}
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
sentinel := errors.New("network down")
rt := &stubRoundTripper{err: sentinel}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error")
}
if errs.IsTyped(err) {
t.Errorf("transport error must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, sentinel) {
t.Errorf("error chain missing sentinel: %v", err)
}
}
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected parse error")
}
if errs.IsTyped(err) {
t.Errorf("parse error must be UNTYPED, got typed %T", err)
}
}
func TestFetchTAT_BrandRouting(t *testing.T) {
tests := []struct {
brand core.LarkBrand
wantURL string
}{
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
}
for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err)
}
if got := rt.gotReq.URL.String(); got != tc.wantURL {
t.Errorf("url = %s, want %s", got, tc.wantURL)
}
})
}
}
func TestFetchTAT_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer srv.Close()
rt := &urlRewriteRT{base: srv.URL}
hc := &http.Client{Transport: rt}
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-canceled
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
if err == nil {
t.Fatal("expected error for canceled context")
}
if errs.IsTyped(err) {
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("error chain missing context.Canceled: %v", err)
}
}
// urlRewriteRT forwards requests to a fixed base URL (test server).
type urlRewriteRT struct{ base string }
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
newURL := r.base + req.URL.Path
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
if err != nil {
return nil, err
}
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}

View File

@@ -129,6 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action,
}
case errs.CategoryAPI:
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
@@ -231,6 +232,22 @@ func ConfigHint(subtype errs.Subtype) string {
return ""
}
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
// layered on by the caller after BuildAPIError returns and overrides this.
func APIHint(subtype errs.Subtype) string {
switch subtype {
case errs.SubtypeConflict:
return "retry later and avoid concurrent duplicate requests on the same resource"
case errs.SubtypeCrossTenant:
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
}
return ""
}
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
missing := extractMissingScopes(resp)
identity := cc.Identity

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
// Each case traces to repo evidence (see codemeta_drive.go comments).
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 1061044: upload with a nonexistent parent folder token. The drive E2E
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
// producer via a nonexistent parent folder → referenced resource missing.
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

@@ -170,6 +170,28 @@ func ErrBare(code int) *ExitError {
return &ExitError{Code: code}
}
// PartialFailureError is the exit signal for a batch / multi-status command that
// has already written an ok:false result envelope to stdout. The per-item
// outcomes are the primary, machine-readable output and live on stdout, so the
// dispatcher sets only the exit code and writes nothing to stderr.
//
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
// so the predicate contract stays narrow, and from a typed *errs.XxxError
// (which owns the stderr error envelope): a partial failure is a result, not an
// error envelope.
type PartialFailureError struct {
Code int
}
func (e *PartialFailureError) Error() string {
return fmt.Sprintf("partial failure (exit %d)", e.Code)
}
// PartialFailure builds the partial-failure exit signal with the given code.
func PartialFailure(code int) *PartialFailureError {
return &PartialFailureError{Code: code}
}
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
// Each typed error owns its wire shape via its own struct tags: Problem fields
// are promoted to the top level through embedding, and extension fields

View File

@@ -61,6 +61,10 @@ func ExitCodeOf(err error) int {
if _, ok := errs.ProblemOf(err); ok {
return ExitCodeForCategory(errs.CategoryOf(err))
}
var pfErr *PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
var exitErr *ExitError
if errors.As(err, &exitErr) {
return exitErr.Code

View File

@@ -270,6 +270,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
Force: opts.Force,
}
if len(plan.ToUpdate) == 0 {
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
}
if len(plan.ToUpdate) > 0 {
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
if installResult == nil || installResult.Err != nil {

View File

@@ -306,6 +306,39 @@ func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t
}
}
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.30",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedAt: "2026-05-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
}
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// migratedEnvelopePaths lists the source-tree prefixes that have been migrated
// to the typed errs.* taxonomy. On these paths, constructing a legacy
// output.ExitError / output.ErrDetail envelope literal directly is forbidden —
// call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/drive/",
}
// legacyOutputImportPath is the import path of the package that declares the
// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local
// name (default or alias) this path is bound to in each file, so an aliased
// import cannot bypass the check.
const legacyOutputImportPath = "github.com/larksuite/cli/internal/output"
// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy
// output.ExitError / output.ErrDetail composite literals on migrated paths.
// forbidigo can ban identifiers but not composite literals, so this AST rule
// covers the gap left after a path is migrated to typed errs.* errors.
//
// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts
// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a
// CompositeLit, so the predicate exit-signal helper is naturally not flagged.
func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation {
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
// Resolve the local name(s) bound to the legacy output import path. A file
// may bind it as the default `output`, an alias (`legacy "...output"`), or a
// dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear
// as bare unqualified idents.
localNames, dotImported := resolveLegacyOutputNames(file)
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok {
out = append(out, Violation{
Rule: "no_legacy_envelope_literal",
Action: ActionReject,
File: path,
Line: fset.Position(lit.Pos()).Line,
Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)",
Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " +
"(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)",
})
}
return true
})
return out
}
// isMigratedEnvelopePath reports whether path falls under any migrated path
// prefix in migratedEnvelopePaths.
func isMigratedEnvelopePath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
for _, prefix := range migratedEnvelopePaths {
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
return true
}
}
return false
}
// resolveLegacyOutputNames walks the file's import declarations and returns the
// set of local names bound to legacyOutputImportPath, plus whether the path was
// dot-imported. Default imports bind the package's own name ("output"); aliased
// imports bind the alias; dot-imports bind names into the file scope.
func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) {
names := make(map[string]struct{})
dotImported := false
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
p := strings.Trim(imp.Path.Value, "`\"")
if p != legacyOutputImportPath {
continue
}
switch {
case imp.Name == nil:
// Default import: local name is the package name "output".
names["output"] = struct{}{}
case imp.Name.Name == ".":
dotImported = true
case imp.Name.Name == "_":
// Blank import cannot reference the types; ignore.
default:
names[imp.Name.Name] = struct{}{}
}
}
return names, dotImported
}
// legacyEnvelopeTypeName reports whether a composite-literal Type names the
// legacy ExitError / ErrDetail envelope and returns the bare type name. It
// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved
// local names for the legacy output import, and — when the package was
// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident.
func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) {
if sel, ok := expr.(*ast.SelectorExpr); ok {
x, ok := sel.X.(*ast.Ident)
if !ok || sel.Sel == nil {
return "", false
}
if _, bound := localNames[x.Name]; !bound {
return "", false
}
return matchLegacyEnvelopeName(sel.Sel.Name)
}
if dotImported {
if ident, ok := expr.(*ast.Ident); ok {
return matchLegacyEnvelopeName(ident.Name)
}
}
return "", false
}
// matchLegacyEnvelopeName returns the name when it is one of the legacy
// envelope type names.
func matchLegacyEnvelopeName(name string) (string, bool) {
switch name {
case "ExitError", "ErrDetail":
return name, true
}
return "", false
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy
// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on
// migrated paths. Those helpers route failures through common.HandleApiResult /
// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and
// downgrade an already-typed network / auth boundary error into an API error.
// forbidigo's errs-typed-only ban does not see them because they are method
// calls, not output.Err* identifiers — this AST rule covers that gap.
//
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
// typed errs.* errors.
//
// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper
// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
// they return the raw response for the caller to classify and do not emit a
// legacy envelope themselves.
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return true
}
if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok {
out = append(out, Violation{
Rule: "no_legacy_runtime_api_call",
Action: ActionReject,
File: path,
Line: fset.Position(call.Pos()).Line,
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
"so failures classify into typed errs.* errors",
})
}
return true
})
return out
}
// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's
// legacy auto-classifying API helper methods.
func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
switch name {
case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID":
return name, true
}
return "", false
}

View File

@@ -593,3 +593,287 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {}
t.Errorf("message must name the offending call: %s", v[0].Message)
}
}
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() *output.ErrDetail {
return &output.ErrDetail{Code: 7}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "ErrDetail") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return output.ErrBare(output.ExitAPI)
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("ErrBare call should pass, got: %+v", v)
}
}
func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) {
// Same offending literal, but outside the migrated path set → not flagged.
src := `package other
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
}
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
if len(v) != 0 {
t.Errorf("_test.go file should be skipped, got: %+v", v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
// import of internal/output cannot bypass the rule: the qualifier is resolved
// from the import declaration, not matched against the literal string "output".
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
src := `package drive
import legacy "github.com/larksuite/cli/internal/output"
func boom() error {
return &legacy.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
// regression where resolving by import path accidentally drops the default
// (non-aliased) `output` case.
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
// a CallExpr, not a composite literal — even under an alias it must not fire.
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
src := `package drive
import legacy "github.com/larksuite/cli/internal/output"
func boom() error {
return legacy.ErrBare(legacy.ExitAPI)
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("ErrBare call should pass, got: %+v", v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
// the composite literal.
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
src := `package drive
import . "github.com/larksuite/cli/internal/output"
func boom() error {
return &ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
// selector on an unrelated package (not the legacy output import path) must not
// trigger a false positive.
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
src := `package drive
import "example.com/other/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("unrelated package selector must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "CallAPI") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
if len(v) != 0 {
t.Errorf("typed wrapper call must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
// RawAPI / DoAPI return the raw response for the caller to classify and do
// not emit a legacy envelope — they are not banned.
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
_, err := runtime.DoAPI(nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
if len(v) != 0 {
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package im
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
if len(v) != 0 {
t.Errorf("test files must be skipped, got: %+v", v)
}
}

View File

@@ -106,6 +106,8 @@ func ScanRepo(root string) ([]Violation, error) {
all = append(all, CheckNoRegistrar(rel, string(src))...)
all = append(all, CheckAdHocSubtype(rel, string(src))...)
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
// Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

@@ -0,0 +1,200 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"net/http"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func newCallAPITypedRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser)
return rt, reg
}
// TestCallAPITyped_HeaderOnlyLogID pins the P1 fix: when the server returns
// log_id only in the x-tt-logid response header (not in the JSON body), the
// typed error still carries it. The legacy runtime.CallAPI path (body-only)
// dropped it.
func TestCallAPITyped_HeaderOnlyLogID(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Headers: http.Header{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"hdr-log-123"},
},
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom"}, // no log_id in body
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if p.LogID != "hdr-log-123" {
t.Errorf("LogID = %q, want %q (lifted from x-tt-logid header)", p.LogID, "hdr-log-123")
}
}
// TestCallAPITyped_BodyLogID confirms body-level log_id still surfaces.
func TestCallAPITyped_BodyLogID(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "body-log-9"},
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.LogID != "body-log-9" {
t.Errorf("LogID = %q, want body-log-9", p.LogID)
}
}
// TestCallAPITyped_Success returns the data object on code 0, and does not leak
// the header log_id into the success payload (log_id surfacing is error-path
// only — success output stays identical to the legacy CallAPI).
func TestCallAPITyped_Success(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Headers: http.Header{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"hdr-log-ok"},
},
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"token": "tok1"}},
})
data, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data["token"] != "tok1" {
t.Errorf("data[token] = %v, want tok1", data["token"])
}
if _, leaked := data["log_id"]; leaked {
t.Errorf("success data must not carry log_id, got: %v", data)
}
}
// TestAPIClassifyContext verifies the classify context is built from the
// runtime: Brand / AppID from config, Identity from the resolved caller, and
// LarkCmd from the running command path.
func TestAPIClassifyContext(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandLark, AppID: "cli_x"}
rt := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+upload"}, cfg, core.AsUser)
cc := rt.APIClassifyContext()
if cc.Brand != "lark" {
t.Errorf("Brand = %q, want lark", cc.Brand)
}
if cc.AppID != "cli_x" {
t.Errorf("AppID = %q, want cli_x", cc.AppID)
}
if cc.Identity != "user" {
t.Errorf("Identity = %q, want user", cc.Identity)
}
if cc.LarkCmd != "+upload" {
t.Errorf("LarkCmd = %q, want +upload", cc.LarkCmd)
}
bot := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+push"}, &core.CliConfig{Brand: core.BrandFeishu, AppID: "y"}, core.AsBot)
if got := bot.APIClassifyContext().Identity; got != "bot" {
t.Errorf("bot Identity = %q, want bot", got)
}
}
// TestCallAPITyped_NonJSON5xx pins that a non-JSON HTTP 5xx (e.g. a gateway 502
// text/html page) is a retryable network/server_error carrying the header
// log_id — not a mis-parsed internal/invalid_response.
func TestCallAPITyped_NonJSON5xx(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Status: 502,
Headers: http.Header{
"Content-Type": []string{"text/html"},
"X-Tt-Logid": []string{"hdr-502"},
},
RawBody: []byte("<html><body>502 Bad Gateway</body></html>"),
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError for non-JSON 5xx, got %T: %v", err, err)
}
if netErr.Subtype != errs.SubtypeNetworkServer {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkServer)
}
if !netErr.Retryable {
t.Error("5xx network error must be retryable")
}
if netErr.LogID != "hdr-502" {
t.Errorf("LogID = %q, want hdr-502 (from header)", netErr.LogID)
}
}
// TestCallAPITyped_5xxNoContentType pins that a 5xx with no Content-Type (which
// the body-only parse would mis-classify as invalid_response) is still a
// retryable network/server_error.
func TestCallAPITyped_5xxNoContentType(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Status: 503,
Headers: http.Header{}, // explicitly no Content-Type header
RawBody: []byte("service unavailable"),
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
var netErr *errs.NetworkError
if !errors.As(err, &netErr) || netErr.Subtype != errs.SubtypeNetworkServer {
t.Fatalf("expected retryable network/server_error, got %T: %v", err, err)
}
if !netErr.Retryable {
t.Error("5xx network error must be retryable")
}
}
// TestCallAPITyped_NonObjectJSON pins that a top-level non-object JSON body
// (e.g. "[]") is rejected as an invalid response, never a silent success ack.
func TestCallAPITyped_NonObjectJSON(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
RawBody: []byte("[]"),
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError for non-object JSON, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -26,6 +26,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
@@ -233,6 +234,133 @@ func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interfa
return HandleApiResult(result, err, "API call failed")
}
// CallAPITyped is the typed-only replacement for CallAPI: it performs the same
// SDK request (buildRequest → APIClient.DoAPI → DoSDKRequest, identical
// transport and query model to CallAPI) and returns the "data" object, but
// classifies failures into typed errs.* errors via errclass.BuildAPIError.
//
// A transport / auth error from the client boundary is already typed and passes
// through unchanged; a non-zero API response code is classified into a typed
// error carrying subtype / code / log_id. Unlike CallAPI it never emits a legacy
// output.ExitError envelope, and never downgrades a typed network/auth error.
//
// It lifts x-tt-logid from the response header (which the body-only parse drops)
// so log_id surfaces on the typed error even when the server returns it only in
// the header.
func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, typedOrInternal(err)
}
resp, err := ac.DoAPI(ctx.ctx, ctx.buildRequest(method, url, params, data))
if err != nil {
return nil, typedOrInternal(err)
}
return ctx.ClassifyAPIResponse(resp)
}
// ClassifyAPIResponse turns a raw *larkcore.ApiResp into the "data" object or a
// typed errs.* error. It is the shared response classifier for typed API paths
// — used by CallAPITyped and by callers that drive the request themselves
// (e.g. file upload via DoAPI). It:
//
// 1. parses the JSON body; an unparseable body on an HTTP error status (a
// gateway 5xx text/html page, an empty body, a missing Content-Type) is
// classified by status — 5xx → retryable network/server_error, 404 →
// not_found, other 4xx → api error — not a misleading invalid-response
// internal error;
// 2. rejects a top-level non-object JSON ([], null, scalar) as an
// invalid-response internal error — never a silent success ack;
// 3. lifts x-tt-logid from the response header onto the typed error so log_id
// surfaces even when the body omits it;
// 4. classifies a non-zero API code via errclass.BuildAPIError, and treats any
// HTTP error status that parsed to code==0 as a status error.
//
// The success "data" object is returned untouched. On a non-zero API code the
// data is returned alongside the typed error, since the response can still
// carry fields a caller needs on failure (e.g. the file_token an overwrite
// returned, for token-stability handling).
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
logID, _ := logIDFromHeader(resp)["log_id"].(string)
result, parseErr := client.ParseJSONResponse(resp)
if parseErr != nil {
if resp.StatusCode >= 400 {
return nil, httpStatusError(resp.StatusCode, resp.RawBody, logID)
}
return nil, client.WrapJSONResponseParseError(parseErr, resp.RawBody)
}
resultMap, ok := result.(map[string]interface{})
if !ok {
e := errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
if logID != "" {
e = e.WithLogID(logID)
}
return nil, e
}
if logID != "" {
if _, present := resultMap["log_id"]; !present {
resultMap["log_id"] = logID
}
}
out, _ := resultMap["data"].(map[string]interface{})
if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil {
return out, apiErr
}
if resp.StatusCode >= 400 {
return out, httpStatusError(resp.StatusCode, resp.RawBody, logID)
}
return out, nil
}
// httpStatusError classifies an HTTP error status whose body is not a usable
// API envelope: 5xx → retryable network/server_error, 404 → not_found, other
// 4xx → api error. The x-tt-logid (when present) is attached for diagnosis.
func httpStatusError(status int, rawBody []byte, logID string) error {
body := TruncateStr(strings.TrimSpace(string(rawBody)), 500)
if status >= 500 {
e := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", status, body).WithCode(status).WithRetryable()
if logID != "" {
e = e.WithLogID(logID)
}
return e
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
e := errs.NewAPIError(subtype, "HTTP %d: %s", status, body).WithCode(status)
if logID != "" {
e = e.WithLogID(logID)
}
return e
}
// typedOrInternal passes an already-typed errs.* error through unchanged and
// lifts a still-untyped one to a typed internal error, so CallAPITyped never
// returns a bare/legacy error.
func typedOrInternal(err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.WrapInternal(err)
}
// APIClassifyContext builds the errclass.ClassifyContext for the running command
// from the runtime config and resolved identity.
func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext {
larkCmd := ""
if ctx.Cmd != nil {
larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ")
}
return errclass.ClassifyContext{
Brand: string(ctx.Config.Brand),
AppID: ctx.Config.AppID,
Identity: string(ctx.As()),
LarkCmd: larkCmd,
}
}
// Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response.
// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options.
//
@@ -552,28 +680,47 @@ func (ctx *RuntimeContext) ValidatePath(path string) error {
// Out prints a success JSON envelope to stdout.
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
ctx.emit(data, meta, false)
ctx.emit(data, meta, false, true)
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
ctx.emit(data, meta, true)
ctx.emit(data, meta, true, true)
}
// emit is the shared success-path emitter. raw=true disables JSON HTML escaping so
// XML/HTML payloads (e.g. DocxXML bodies) are preserved verbatim; otherwise behavior
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
// and returns the partial-failure exit signal. Use it for batch operations
// where some items failed but the per-item outcomes are the primary output:
// the full result (summary + per-item statuses) stays machine-readable on
// stdout, the process exits non-zero, and nothing is written to stderr.
//
// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the
// envelope's ok field honestly reports failure instead of a misleading
// ok:true, and the exit signal is distinct from the predicate-only ErrBare.
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
ctx.emit(data, meta, false, false)
if ctx.outputErr != nil {
return ctx.outputErr
}
return output.PartialFailure(output.ExitAPI)
}
// emit is the shared stdout envelope emitter; ok sets the envelope's ok field
// (true for success, false for a partial-failure result). raw=true disables JSON
// HTML escaping so XML/HTML payloads (e.g. DocxXML bodies) are preserved
// verbatim; otherwise behavior
// is identical — content-safety scanning and race-safe first-error capture via
// outputErrOnce apply in both modes.
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw bool) {
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok bool) {
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
if scanResult.Blocked {
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
return
}
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
@@ -1029,6 +1176,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
}
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")

View File

@@ -96,3 +96,76 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
t.Fatal("did not expect completion func for --format when disabled")
}
}
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+read",
Description: "test read",
HasFormat: true,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+read"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered on HasFormat shortcut")
}
}
func TestShortcutMount_JsonFlag_SkippedWhenConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+update",
Description: "test update",
HasFormat: true,
Flags: []Flag{
{Name: "json", Desc: "body JSON object", Required: true},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+update"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --json flag exists (from custom Flags), but should be the string type, not bool.
flag := cmd.Flags().Lookup("json")
if flag == nil {
t.Fatal("expected --json flag from custom Flags")
}
if flag.DefValue != "" {
t.Errorf("expected empty default (string flag), got %q", flag.DefValue)
}
}
func TestShortcutMount_JsonFlag_RegisteredWithoutHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+write",
Description: "test write",
HasFormat: false,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+write"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --format is now registered for all shortcuts (regardless of HasFormat),
// so --json should also be present.
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered even when HasFormat is false")
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// TestOutPartialFailure pins the batch / multi-status contract: the result
// rides on stdout as an ok:false envelope (carrying the full payload), and the
// returned error is the typed partial-failure exit signal (ExitAPI), distinct
// from the predicate-only ErrBare.
func TestOutPartialFailure(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, core.AsUser)
payload := map[string]interface{}{
"summary": map[string]interface{}{"uploaded": 1, "failed": 1},
"items": []map[string]interface{}{
{"rel_path": "a.txt", "action": "uploaded"},
{"rel_path": "b.txt", "action": "failed", "error": "boom"},
},
}
err := rt.OutPartialFailure(payload, nil)
// 1) typed partial-failure exit signal
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
// 2) stdout envelope reports ok:false but still carries the full payload
// (both the succeeded and failed items) — consistent with a success Out().
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String())
}
if env.OK {
t.Errorf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String())
}
items, _ := env.Data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String())
}
}

View File

@@ -11,7 +11,7 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -152,13 +152,13 @@ var DriveAddComment = common.Shortcut{
if docRef.Kind == "sheet" {
blockID := strings.TrimSpace(runtime.Str("block-id"))
if blockID == "" {
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)").WithParam("--block-id")
}
if _, err := parseSheetCellRef(blockID); err != nil {
return err
}
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
}
return nil
}
@@ -167,20 +167,20 @@ var DriveAddComment = common.Shortcut{
return err
}
if runtime.Bool("full-comment") {
return output.ErrValidation("--full-comment is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return output.ErrValidation("--selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
}
return nil
}
selection := runtime.Str("selection-with-ellipsis")
blockID := strings.TrimSpace(runtime.Str("block-id"))
if strings.TrimSpace(selection) != "" && blockID != "" {
return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis and --block-id are mutually exclusive")
}
if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") {
return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment cannot be used with --selection-with-ellipsis or --block-id")
}
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
@@ -188,7 +188,7 @@ var DriveAddComment = common.Shortcut{
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
}
return nil
@@ -398,7 +398,7 @@ var DriveAddComment = common.Shortcut{
}
blockID = match.AnchorBlockID
if strings.TrimSpace(blockID) == "" {
return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id")
}
selectedMatch = idx
fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID)
@@ -418,7 +418,7 @@ var DriveAddComment = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken))
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
requestPath,
nil,
@@ -473,7 +473,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
}
if token, ok := extractURLToken(raw, "/wiki/"); ok {
@@ -495,16 +495,16 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw)
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
}
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -519,7 +519,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
@@ -535,7 +535,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docRef.Token},
@@ -549,13 +549,13 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
return resolvedCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType == "slides" && mode == commentModeFull {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but slide comments require --block-id <slide-block-type>!<xml-id>; --full-comment is not applicable", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slide comments require --block-id <slide-block-type>!<xml-id>; --full-comment is not applicable", objType)
}
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
}
if objType == "sheet" {
// Sheet comments are handled via the sheet fast path in Execute.
@@ -592,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -663,16 +663,14 @@ func parseLocateDocResult(result map[string]interface{}) locateDocResult {
func selectLocateMatch(result locateDocResult) (locateDocMatch, int, error) {
if len(result.Matches) == 0 {
return locateDocMatch{}, 0, output.ErrValidation("locate-doc did not find any matching block")
return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "locate-doc did not find any matching block").WithParam("--selection-with-ellipsis")
}
if len(result.Matches) > 1 {
return locateDocMatch{}, 0, output.ErrWithHint(
output.ExitValidation,
"ambiguous_match",
fmt.Sprintf("locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)),
"narrow --selection-with-ellipsis until only one block matches",
)
return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)).
WithHint("narrow --selection-with-ellipsis until only one block matches").
WithParam("--selection-with-ellipsis")
}
return result.Matches[0], 1, nil
@@ -705,15 +703,15 @@ func summarizeLocateMatch(match locateDocMatch) string {
func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return nil, output.ErrValidation("--content cannot be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").WithParam("--content")
}
var inputs []commentReplyElementInput
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
return nil, output.ErrValidation("--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
}
if len(inputs) == 0 {
return nil, output.ErrValidation("--content must contain at least one reply element")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")
}
replyElements := make([]map[string]interface{}, 0, len(inputs))
@@ -724,7 +722,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
switch elementType {
case "text":
if strings.TrimSpace(input.Text) == "" {
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=text requires non-empty text", index).WithParam("--content")
}
// Measure the raw rune count of the user input — that is what
// the server actually counts. byte width and post-escape form
@@ -734,13 +732,11 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
runes := utf8.RuneCountInString(input.Text)
totalRunes += runes
if totalRunes > maxCommentTotalRunes {
return nil, output.ErrWithHint(
output.ExitValidation,
"text_too_long",
fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
totalRunes, index, runes, maxCommentTotalRunes),
fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes),
)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
totalRunes, index, runes, maxCommentTotalRunes).
WithHint("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes).
WithParam("--content")
}
// Escape '<' and '>' so the rendered comment displays them as
// literal characters instead of being interpreted as markup
@@ -754,7 +750,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
case "mention_user":
mentionUser := firstNonEmptyString(input.MentionUser, input.Text)
if mentionUser == "" {
return nil, output.ErrValidation("--content element #%d type=mention_user requires text or mention_user", index)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=mention_user requires text or mention_user", index).WithParam("--content")
}
replyElements = append(replyElements, map[string]interface{}{
"type": "mention_user",
@@ -763,14 +759,14 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
case "link":
link := firstNonEmptyString(input.Link, input.Text)
if link == "" {
return nil, output.ErrValidation("--content element #%d type=link requires text or link", index)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=link requires text or link", index).WithParam("--content")
}
replyElements = append(replyElements, map[string]interface{}{
"type": "link",
"link": link,
})
default:
return nil, output.ErrValidation("--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type).WithParam("--content")
}
}
@@ -827,17 +823,17 @@ func anchorBlockIDForDryRun(blockID string) string {
func parseSlidesBlockRef(blockID string) (string, string, error) {
blockID = strings.TrimSpace(blockID)
if blockID == "" {
return "", "", output.ErrValidation("slide comments require --block-id in <slide-block-type>!<xml-id> format")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide comments require --block-id in <slide-block-type>!<xml-id> format").WithParam("--block-id")
}
parts := strings.SplitN(blockID, "!", 2)
if len(parts) != 2 {
return "", "", output.ErrValidation("slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID).WithParam("--block-id")
}
parsedType := strings.TrimSpace(parts[0])
parsedID := strings.TrimSpace(parts[1])
if parsedType == "" || parsedID == "" {
return "", "", output.ErrValidation("slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID).WithParam("--block-id")
}
return parsedID, parsedType, nil
}
@@ -865,7 +861,7 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} {
func parseSheetCellRef(input string) (*sheetAnchor, error) {
parts := strings.SplitN(input, "!", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input).WithParam("--block-id")
}
sheetID := parts[0]
cell := strings.TrimSpace(parts[1])
@@ -876,7 +872,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
i++
}
if i == 0 || i >= len(cell) {
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id cell reference %q is invalid (expected e.g. D6)", cell).WithParam("--block-id")
}
colStr := strings.ToUpper(cell[:i])
rowStr := cell[i:]
@@ -890,7 +886,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
row, err := strconv.Atoi(rowStr)
if err != nil || row < 1 {
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id row %q is invalid (must be >= 1)", rowStr).WithParam("--block-id")
}
row-- // convert to 0-based
@@ -898,7 +894,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
}
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
@@ -917,11 +913,11 @@ func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken strin
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
}
meta, ok := metas[0].(map[string]interface{})
if !ok {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
}
return common.GetString(meta, "title"), nil
}
@@ -936,23 +932,19 @@ func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken
return title, extension, nil
}
if strings.TrimSpace(title) == "" {
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title",
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title").
WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()).
WithParam("--doc")
}
extensionLabel := extension
if extensionLabel == "" {
extensionLabel = "no extension"
}
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel),
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel).
WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()).
WithParam("--doc")
}
func fileCommentExtension(title string) string {
@@ -993,9 +985,9 @@ func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
return nil
}
if resolvedObjType != "" {
return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
}
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file comments only support full comments; omit --block-id and --selection-with-ellipsis")
}
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
@@ -1006,7 +998,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
blockID := strings.TrimSpace(runtime.Str("block-id"))
if blockID == "" {
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)").WithParam("--block-id")
}
anchor, err := parseSheetCellRef(blockID)
if err != nil {
@@ -1019,7 +1011,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n",
common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
@@ -1054,7 +1046,7 @@ func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTa
fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
@@ -1097,7 +1089,7 @@ func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef)
fmt.Fprintf(runtime.IO().ErrOut, "Creating slide block comment in %s (block_id=%s, slide_block_type=%s)\n",
common.MaskToken(docRef.Token), blockID, slideBlockType)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}

View File

@@ -9,11 +9,32 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// assertContentValidationHint asserts err is a typed *errs.ValidationError
// carrying SubtypeInvalidArgument, Param "--content", and a Hint containing
// the given substring. The over-cap message now flows through a typed
// ValidationError instead of the legacy *output.ExitError.Detail shape.
func assertContentValidationHint(t *testing.T, err error, wantHint string) {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != "--content" {
t.Errorf("Param = %q, want %q", valErr.Param, "--content")
}
if !strings.Contains(valErr.Hint, wantHint) {
t.Errorf("expected hint substring %q, got %q", wantHint, valErr.Hint)
}
}
func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
t.Helper()
@@ -421,14 +442,8 @@ func TestParseCommentReplyElementsTextLength(t *testing.T) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
if tt.wantHint != "" {
// Hint lives on ExitError.Detail.Hint, not err.Error().
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
}
// Hint lives on the typed ValidationError, not err.Error().
assertContentValidationHint(t, err, tt.wantHint)
}
return
}
@@ -458,11 +473,11 @@ func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
if err == nil {
t.Fatal("expected over-cap error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
hint := exitErr.Detail.Hint
hint := valErr.Hint
// The hint must explicitly call out that splitting does NOT help.
if !strings.Contains(hint, "does NOT help") {

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,7 +44,7 @@ var permApplyURLMarkers = []struct {
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", output.ErrValidation("--token is required")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if strings.Contains(raw, "://") {
@@ -58,10 +58,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
}
}
if token == "" {
return "", "", output.ErrValidation(
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
)
).WithParam("--token")
}
} else {
token = raw
@@ -71,10 +71,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
docType = explicitType
}
if docType == "" {
return "", "", output.ErrValidation(
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(permApplyTypes, ", "),
)
).WithParam("--type")
}
return token, docType, nil
}
@@ -125,7 +125,7 @@ var DriveApplyPermission = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n",
runtime.Str("perm"), docType, common.MaskToken(token))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -72,7 +72,7 @@ var DriveCreateFolder = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target)
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
@@ -84,7 +84,7 @@ var DriveCreateFolder = common.Shortcut{
folderToken := common.GetString(data, "token")
if folderToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "drive create_folder succeeded but returned no folder token (data.token)")
}
out := map[string]interface{}{
"created": true,
@@ -108,14 +108,14 @@ var DriveCreateFolder = common.Shortcut{
func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error {
if spec.Name == "" {
return output.ErrValidation("--name must not be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty").WithParam("--name")
}
if nameBytes := len([]byte(spec.Name)); nameBytes > 256 {
return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 256 bytes (got %d)", nameBytes).WithParam("--name")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
return nil

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -84,7 +84,7 @@ var DriveCreateShortcut = common.Shortcut{
common.MaskToken(spec.FolderToken),
)
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/files/create_shortcut",
nil,
@@ -118,19 +118,19 @@ var DriveCreateShortcut = common.Shortcut{
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first").WithParam("--type")
}
if spec.FileType == "folder" {
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: folder. The create_shortcut API only supports Drive files, not folders").WithParam("--type")
}
if !driveCreateShortcutAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType).WithParam("--type")
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -312,24 +313,24 @@ func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
t.Fatal("expected API error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *errs.APIError, got %T (%v)", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
if output.ExitCodeOf(err) != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitAPI)
}
if exitErr.Detail.Type != tt.wantType {
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
if string(apiErr.Subtype) != tt.wantType {
t.Fatalf("subtype = %q, want %q", apiErr.Subtype, tt.wantType)
}
if exitErr.Detail.Code != tt.code {
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
if apiErr.Code != tt.code {
t.Fatalf("code = %d, want %d", apiErr.Code, tt.code)
}
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
if !strings.Contains(apiErr.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", apiErr.Message, tt.wantMsgPart)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
if !strings.Contains(apiErr.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", apiErr.Hint, tt.wantHint)
}
})
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -81,7 +81,7 @@ var DriveDelete = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
map[string]interface{}{"type": spec.FileType},
@@ -94,7 +94,7 @@ var DriveDelete = common.Shortcut{
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
@@ -136,13 +136,13 @@ var DriveDelete = common.Shortcut{
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported").WithParam("--type")
}
if !driveDeleteAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType).WithParam("--type")
}
return nil
}

View File

@@ -10,8 +10,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,7 +44,7 @@ var DriveDownload = common.Shortcut{
overwrite := runtime.Bool("overwrite")
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if outputPath == "" {
@@ -53,10 +53,10 @@ var DriveDownload = common.Shortcut{
// Early path validation + overwrite check
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return output.ErrValidation("unsafe output path: %s", resolveErr)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
@@ -66,7 +66,7 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapDriveNetworkErr(err, "download failed: %s", err)
}
defer resp.Body.Close()
@@ -75,7 +75,7 @@ var DriveDownload = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return driveSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)

View File

@@ -17,9 +17,9 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
const (
@@ -823,64 +823,37 @@ func registerDownload(reg *httpmock.Registry, fileToken, body string) {
func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) {
t.Helper()
if err == nil {
t.Fatal("expected duplicate_remote_path error, got nil")
t.Fatal("expected duplicate rel_path validation error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" {
t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail)
if validationErr.Hint == "" {
t.Fatal("duplicate validation error should carry a recovery hint so AI consumers know the next action")
}
detailMap, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail)
if len(validationErr.Params) == 0 {
t.Fatal("duplicate validation error should carry at least one param")
}
duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath)
if !ok {
t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"])
}
if len(duplicates) == 0 {
t.Fatal("duplicate detail should include at least one rel_path group")
}
if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey {
t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap)
}
var matched bool
for _, duplicate := range duplicates {
if duplicate.RelPath != relPath {
continue
}
matched = true
if len(duplicate.Entries) != len(tokens) {
t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath)
}
for i, token := range tokens {
if duplicate.Entries[i].FileToken != token {
t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token)
}
if duplicate.Entries[i].Type == "" {
t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath)
}
var matched *errs.InvalidParam
for i := range validationErr.Params {
if validationErr.Params[i].Name == relPath {
matched = &validationErr.Params[i]
break
}
}
if !matched {
t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates)
if matched == nil {
t.Fatalf("duplicate params missing rel_path group %q: %#v", relPath, validationErr.Params)
}
raw, marshalErr := json.Marshal(exitErr.Detail.Detail)
if marshalErr != nil {
t.Fatalf("marshal detail: %v", marshalErr)
}
text := string(raw)
if !strings.Contains(text, relPath) {
t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text)
if matched.Reason == "" {
t.Fatalf("duplicate param for rel_path %q missing reason", relPath)
}
for _, token := range tokens {
if !strings.Contains(text, token) {
t.Fatalf("duplicate detail missing token %q: %s", token, text)
if !strings.Contains(matched.Reason, token) {
t.Fatalf("duplicate param reason missing token %q: %s", token, matched.Reason)
}
}
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// wrapDriveNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving its subtype / code / log_id from the runtime boundary),
// and only wraps a raw, unclassified error as a transport-level network error.
func wrapDriveNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."
// - Other errors → "cannot read file: ..."
func driveInputStatError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
}
// driveSaveError maps a FileIO.Save error to a typed error. Path validation
// failures are validation errors (exit code 2); mkdir / write failures are
// internal file-I/O errors (exit code 5).
func driveSaveError(err error) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
}
}
// appendDriveExportRecoveryHint attaches a recovery hint to err while preserving
// its original classification (typed subtype/code or legacy detail), only falling
// back to a typed internal error when err is unclassified.
func appendDriveExportRecoveryHint(err error, hint string) error {
if err == nil {
return nil
}
// An already-typed error keeps its own category/subtype/code/log_id
// (per ERROR_CONTRACT.md "propagate typed errors unchanged"); we only
// append the recovery hint. p points at the embedded Problem, so the
// mutation is reflected in the returned err.
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
// Legacy *output.ExitError fallback: preserve the original error's
// class/exit code by appending the hint in place rather than downgrading
// to api/server_error.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
exitErr.Detail.Hint = exitErr.Detail.Hint + "\n" + hint
} else {
exitErr.Detail.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
}

View File

@@ -5,13 +5,12 @@ package drive
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -107,7 +106,7 @@ var DriveExport = common.Shortcut{
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.DoAPIJSONWithLogID(
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
@@ -122,11 +121,11 @@ var DriveExport = common.Shortcut{
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
@@ -207,11 +206,7 @@ var DriveExport = common.Shortcut{
status.FileToken,
recoveryCommand,
)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
@@ -225,7 +220,7 @@ var DriveExport = common.Shortcut{
if msg == "" {
msg = status.StatusLabel()
}
return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket)
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
@@ -238,14 +233,7 @@ var DriveExport = common.Shortcut{
ticket,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false

View File

@@ -15,9 +15,9 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -127,48 +127,48 @@ func (s driveExportStatus) StatusLabel() string {
// backend request is sent.
func validateDriveExportSpec(spec driveExportSpec) error {
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
switch spec.DocType {
case "doc", "docx", "sheet", "bitable", "slides":
default:
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
default:
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
}
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
return output.ErrValidation("--file-extension markdown only supports --doc-type docx")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
}
if spec.FileExtension == "base" && spec.DocType != "bitable" {
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
}
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
return output.ErrValidation("--file-extension pptx only supports --doc-type slides")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
}
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
}
if strings.TrimSpace(spec.SubID) != "" {
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
}
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
}
}
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
}
return nil
@@ -186,14 +186,14 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
body["sub_id"] = spec.SubID
}
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return "", err
}
ticket := common.GetString(data, "ticket")
if ticket == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket is missing")
}
return ticket, nil
}
@@ -201,7 +201,7 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
// getDriveExportStatus fetches the current backend state for a previously
// created export task.
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
@@ -251,12 +251,12 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo
// Overwrite check via FileIO.Stat
if !overwrite {
if _, statErr := fio.Stat(target); statErr == nil {
return "", output.ErrValidation("output file already exists: %s (use --overwrite to replace)", target)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", target)
}
}
if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
return "", driveSaveError(err)
}
resolvedPath, _ := fio.ResolvePath(target)
if resolvedPath == "" {
@@ -269,7 +269,7 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo
// file name, and returns metadata about the saved file.
func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) {
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
@@ -277,10 +277,24 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
}, larkcore.WithFileDownload())
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
return nil, wrapDriveNetworkErr(err, "download failed: %s", err)
}
if apiResp.StatusCode >= 400 {
return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
subtype := errs.SubtypeNetworkTransport
if apiResp.StatusCode >= 500 {
subtype = errs.SubtypeNetworkServer
}
e := errs.NewNetworkError(subtype, "download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)).WithCode(apiResp.StatusCode)
// Mirror internal/client streamLogID: fall back to the request-id header
// when log-id is absent so the diagnostic ID is still populated.
logID := strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyRequestId))
}
if logID != "" {
e = e.WithLogID(logID)
}
return nil, e
}
fileName := strings.TrimSpace(preferredName)

View File

@@ -6,7 +6,7 @@ package drive
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,7 +30,7 @@ var DriveExportDownload = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
return nil
},

View File

@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -360,12 +361,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
t.Fatal("expected error for missing document object, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if !strings.Contains(intErr.Message, "missing document object") {
t.Fatalf("error message = %q, want mention of missing document object", intErr.Message)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
}
}
@@ -396,12 +403,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
t.Fatal("expected error for missing document.content, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if !strings.Contains(intErr.Message, "missing document.content") {
t.Fatalf("error message = %q, want mention of missing document.content", intErr.Message)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
}
}
@@ -688,21 +701,25 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
t.Fatal("expected download recovery error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
// The download itself succeeds; the local "file already exists" failure is a
// validation error. The recovery-hint wrapper must preserve that typed class
// (exit 2) instead of downgrading it to api/server_error (exit 1), per
// ERROR_CONTRACT.md "propagate typed errors unchanged".
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError (preserved class), got %T", err)
}
if !strings.Contains(exitErr.Detail.Message, "already exists") {
t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message)
if !strings.Contains(valErr.Message, "already exists") {
t.Fatalf("message missing overwrite guidance: %q", valErr.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") {
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
if !strings.Contains(valErr.Hint, "ticket=tk_ready") {
t.Fatalf("hint missing ticket: %q", valErr.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") {
t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint)
if !strings.Contains(valErr.Hint, "file_token=box_ready") {
t.Fatalf("hint missing file token: %q", valErr.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
if !strings.Contains(valErr.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) {
t.Fatalf("hint missing recovery command: %q", valErr.Hint)
}
}
@@ -856,18 +873,26 @@ func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
// The poll error is now a typed *errs.APIError (runtime.CallAPITyped).
// The recovery-hint wrapper must preserve that error's class and exit code
// (NOT downgrade it) and only append the recovery hint to the Problem in place.
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T (%v)", err, err)
}
if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") {
t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message)
// Lark code 999 is unknown to the classifier, so it maps to CategoryAPI →
// ExitAPI — the wrapper must keep that, not force a different exit code.
if output.ExitCodeOf(err) != output.ExitAPI {
t.Fatalf("exit code = %d, want preserved %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
}
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") {
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
if !strings.Contains(p.Message, "temporary backend failure") {
t.Fatalf("message missing last poll error: %q", p.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "ticket=tk_poll_fail") {
t.Fatalf("hint missing ticket: %q", p.Hint)
}
if !strings.Contains(p.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") {
t.Fatalf("hint missing recovery command: %q", p.Hint)
}
}

View File

@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -161,10 +161,10 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64,
// and format-specific size limits before planning the upload path.
info, err := fio.Stat(spec.FilePath)
if err != nil {
return 0, common.WrapInputStatError(err)
return 0, driveInputStatError(err)
}
if !info.Mode().IsRegular() {
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file")
}
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
return 0, err

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -95,7 +95,7 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
importInfo, err := runtime.FileIO().Stat(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", driveInputStatError(err)
}
fileSize := importInfo.Size()
@@ -142,7 +142,7 @@ func buildImportMediaExtra(filePath, docType string) (string, error) {
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
})
if err != nil {
return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err)
return "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err)
}
return string(extraBytes), nil
}
@@ -178,20 +178,20 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
if ext == "csv" {
// CSV is the only source format whose limit depends on the target type.
return output.ErrValidation(
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"file %s exceeds %s import limit for .csv when importing as %s",
common.FormatSize(fileSize),
common.FormatSize(limit),
docType,
)
).WithParam("--file")
}
return output.ErrValidation(
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"file %s exceeds %s import limit for .%s",
common.FormatSize(fileSize),
common.FormatSize(limit),
ext,
)
).WithParam("--file")
}
// validateDriveImportSpec enforces the CLI-level compatibility rules before any
@@ -199,18 +199,18 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx, .pptx)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
}
switch spec.DocType {
case "docx", "sheet", "bitable", "slides":
default:
return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType).WithParam("--type")
}
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
}
typeAllowed := false
@@ -236,21 +236,21 @@ func validateDriveImportSpec(spec driveImportSpec) error {
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}
return output.ErrValidation("file type mismatch: %s", hint)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file type mismatch: %s", hint)
}
if strings.TrimSpace(spec.FolderToken) != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
if strings.TrimSpace(spec.TargetToken) != "" {
if spec.DocType != "bitable" {
return output.ErrValidation("--target-token is only supported when --type is bitable")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-token is only supported when --type is bitable").WithParam("--target-token")
}
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--target-token")
}
}
@@ -308,14 +308,14 @@ func driveImportTaskResultCommand(ticket string) string {
// createDriveImportTask creates the server-side import task after the media
// upload has produced a reusable file token.
func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
if err != nil {
return "", err
}
ticket := common.GetString(data, "ticket")
if ticket == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "no ticket returned from import_tasks")
}
return ticket, nil
}
@@ -323,10 +323,10 @@ func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec,
// getDriveImportStatus fetches the current state of an import task by ticket.
func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) {
if err := validate.ResourceName(ticket, "--ticket"); err != nil {
return driveImportStatus{}, output.ErrValidation("%s", err)
return driveImportStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)),
nil,
@@ -391,7 +391,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
if msg == "" {
msg = status.StatusLabel()
}
return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg)
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
}
}
if !hadSuccessfulPoll && lastErr != nil {

View File

@@ -9,7 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,18 +37,18 @@ var DriveInspect = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
if raw == "" {
return output.ErrValidation("--url cannot be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
}
_, ok := common.ParseResourceURL(raw)
if !ok {
// Not a recognized URL pattern.
if strings.Contains(raw, "://") {
return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
}
// Bare token: --type is required.
if strings.TrimSpace(runtime.Str("type")) == "" {
return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
}
}
return nil
@@ -111,7 +111,7 @@ var DriveInspect = common.Shortcut{
// Step 2: If type is "wiki", unwrap via get_node API.
if docType == "wiki" {
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
@@ -128,7 +128,7 @@ var DriveInspect = common.Shortcut{
nodeToken := common.GetString(node, "node_token")
if objType == "" || objToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
}
wikiNode = map[string]interface{}{

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"net/http"
@@ -17,6 +18,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -1338,9 +1340,20 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("Validate() error = %T %v, want *errs.ValidationError", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(verr.Error(), "mutually exclusive") {
t.Fatalf("Validate() error = %v, want mutually exclusive error", err)
}
// Multi-flag conflict carries no single Param.
if verr.Param != "" {
t.Fatalf("Param = %q, want empty for multi-flag conflict", verr.Param)
}
}
func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
@@ -1361,9 +1374,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--wiki-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty wiki-token error", err)
}
assertDriveValidationParam(t, err, "--wiki-token", "--wiki-token cannot be empty")
}
func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
@@ -1384,9 +1395,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty file-token error", err)
}
assertDriveValidationParam(t, err, "--file-token", "--file-token cannot be empty")
}
func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
@@ -1407,8 +1416,25 @@ func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--folder-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty folder-token error", err)
assertDriveValidationParam(t, err, "--folder-token", "--folder-token cannot be empty")
}
// assertDriveValidationParam asserts err is a typed *errs.ValidationError with
// SubtypeInvalidArgument, the given Param, and a message containing wantMsg.
func assertDriveValidationParam(t *testing.T, err error, wantParam, wantMsg string) {
t.Helper()
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument)
}
if verr.Param != wantParam {
t.Fatalf("Param = %q, want %q", verr.Param, wantParam)
}
if !strings.Contains(verr.Error(), wantMsg) {
t.Fatalf("error = %q, want substring %q", verr.Error(), wantMsg)
}
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -74,14 +74,14 @@ var DriveMove = common.Shortcut{
return err
}
if rootToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "get root folder token failed, root folder is empty")
}
spec.FolderToken = rootToken
}
fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)),
nil,
@@ -95,7 +95,7 @@ var DriveMove = common.Shortcut{
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "move folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID)
@@ -139,14 +139,14 @@ var DriveMove = common.Shortcut{
// getRootFolderToken resolves the caller's Drive root folder token so other
// commands can safely use it as a default destination.
func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
if err != nil {
return "", err
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root_folder/meta returned no token")
}
return token, nil

View File

@@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,15 +47,15 @@ func (s driveMoveSpec) RequestBody() map[string]interface{} {
func validateDriveMoveSpec(spec driveMoveSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if strings.TrimSpace(spec.FolderToken) != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
if !driveMoveAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType).WithParam("--type")
}
return nil
}
@@ -109,10 +109,10 @@ func driveTaskCheckParams(taskID string) map[string]interface{} {
// folder move or delete task.
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return driveTaskCheckStatus{}, output.ErrValidation("%s", err)
return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
if err != nil {
return driveTaskCheckStatus{}, err
}
@@ -163,7 +163,7 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed")
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
}
}

View File

@@ -15,8 +15,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -88,26 +88,26 @@ var DrivePull = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-local requires --yes (high-risk: deletes local files absent from Drive)").WithParam("--yes")
}
return nil
},
@@ -143,18 +143,18 @@ var DrivePull = common.Shortcut{
// remove the wrong files outside cwd.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
// rootRelToCwd is the localDir form FileIO.Save accepts (it
// rejects absolute paths). For cwd itself it becomes ".", which
// joins cleanly with the rel_paths returned by the lister.
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir")
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
@@ -174,7 +174,7 @@ var DrivePull = common.Shortcut{
// treated as orphaned.
remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
var downloaded, skipped, failed, deletedLocal int
@@ -293,26 +293,25 @@ var DrivePull = common.Shortcut{
// Item-level failures (download error, dir/file conflict, delete
// error) must surface as a non-zero exit so AI / script callers
// don't have to reach into summary.failed to detect a partial
// sync. The same structured payload rides along in error.detail
// so forensics aren't lost. When --delete-local was skipped
// because of an earlier download failure, callers see
// deleted_local=0 plus the download failure that aborted it,
// which is what makes the partial state self-explanatory.
// sync. On any failure the structured payload (summary + items +
// a "note" carrying the human guidance) is written to stdout as an
// ok:false result via OutPartialFailure, which also sets the exit
// code, so the per-item context is never lost. When --delete-local
// was skipped because
// of an earlier download failure, callers see deleted_local=0
// plus the download failure that aborted it, which is what makes
// the partial state self-explanatory.
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
note := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
if deleteLocal && downloadFailed > 0 {
msg += " (--delete-local was skipped because the download pass had failures)"
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
note += " (--delete-local was skipped because the download pass had failures)"
}
payload["note"] = note
}
if failed > 0 {
return runtime.OutPartialFailure(payload, nil)
}
runtime.Out(payload, nil)
return nil
},
@@ -326,14 +325,14 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
return wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return driveSaveError(err)
}
if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err)
@@ -350,10 +349,10 @@ func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime
}
resolved, err := runtime.FileIO().ResolvePath(target)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err)
}
if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil {
return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err)
return errs.NewInternalError(errs.SubtypeFileIO, "cannot preserve remote modified_time on local file: %s", err).WithCause(err)
}
return nil
}
@@ -437,7 +436,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime}
remotePaths[rel] = struct{}{}
default:
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remotePaths, nil
@@ -467,7 +466,7 @@ func drivePullWalkLocal(root string) ([]string, error) {
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
}
return paths, nil
}

View File

@@ -478,9 +478,9 @@ func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
// already a directory locally. SafeOutputPath would refuse to overwrite
// the directory at write time, but if --if-exists=skip silently swallows
// the collision the caller sees "skipped" and assumes the mirror is
// in sync. The fix surfaces it as a structured `partial_failure`
// ExitError (non-zero exit + items[] in error.detail) under both skip
// and overwrite policies so callers can react via exit code.
// in sync. The fix surfaces it as a partial-failure (ok:false items[] payload
// on stdout + non-zero exit) under both skip and overwrite policies so callers
// can react via exit code.
func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
for _, policy := range []string{"overwrite", "skip"} {
t.Run(policy, func(t *testing.T) {
@@ -515,8 +515,8 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
"--if-exists", policy,
"--as", "bot",
}, f, stdout)
detail := assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullDetail(t, detail)
assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("[%s] summary.failed = %v, want 1", policy, got)
}
@@ -529,9 +529,6 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
if msg, _ := items[0]["error"].(string); !strings.Contains(msg, "is a directory") {
t.Errorf("[%s] error message should mention the directory conflict, got: %q", policy, msg)
}
if stdout.Len() != 0 {
t.Errorf("[%s] stdout should be empty on partial_failure, got: %s", policy, stdout.String())
}
})
}
}
@@ -900,8 +897,8 @@ func TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder(t *testing
// TestDrivePullDeleteLocalCountsFailureInSummary pins the contract that
// a failed delete shows up in summary.failed (not just in items[]) AND
// surfaces as a partial_failure ExitError so callers can detect the
// half-synced state via exit code. Before the fix, the delete_failed
// surfaces as a non-zero exit (partial-failure signal) so callers can detect
// the half-synced state via exit code. Before the fix, the delete_failed
// branches appended an item but left `failed` at zero AND returned nil,
// so the JSON envelope reported `ok=true`+`exit=0` even when the mirror
// was incomplete. Setup forces os.Remove to fail by making the file's
@@ -947,8 +944,8 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) {
"--yes",
"--as", "bot",
}, f, stdout)
detail := assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullDetail(t, detail)
assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1 (delete_failed must increment failed)", got)
}
@@ -958,15 +955,12 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) {
if len(items) != 1 || items[0]["action"] != "delete_failed" {
t.Errorf("expected one items[] entry with action=delete_failed, got: %#v", items)
}
if stdout.Len() != 0 {
t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String())
}
}
// TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero pins the
// gating contract for --delete-local: when the download pass produced
// any failure, the delete walk MUST be skipped entirely and the command
// MUST exit non-zero with type=partial_failure. The half-synced state
// MUST exit non-zero via the partial-failure signal. The half-synced state
// where some Drive files are missing locally AND some local-only files
// have been removed is never observable.
func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
@@ -1014,12 +1008,12 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
"--yes",
"--as", "bot",
}, f, stdout)
exitErr := assertDrivePullPartialFailure(t, err)
if !strings.Contains(exitErr.Detail.Message, "--delete-local was skipped") {
t.Errorf("expected message to mention --delete-local skip, got: %q", exitErr.Detail.Message)
assertDrivePullPartialFailure(t, err)
if note := drivePullStdoutNote(t, stdout.Bytes()); !strings.Contains(note, "--delete-local was skipped") {
t.Errorf("expected note to mention --delete-local skip, got: %q", note)
}
summary, items := splitDrivePullDetail(t, exitErr)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1", got)
}
@@ -1036,9 +1030,6 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
if _, statErr := os.Stat(stale); statErr != nil {
t.Fatalf("stale.txt must survive when --delete-local is skipped after a download failure; stat err=%v", statErr)
}
if stdout.Len() != 0 {
t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String())
}
}
// TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the
@@ -1343,49 +1334,60 @@ func mustReadFile(t *testing.T, path, want string) {
}
}
// assertDrivePullPartialFailure asserts that err is the structured
// partial_failure ExitError +pull returns when any item-level failure
// happens, and returns the unwrapped *ExitError so the caller can drill
// into Detail.Detail without re-doing the type assertion.
func assertDrivePullPartialFailure(t *testing.T, err error) *output.ExitError {
// assertDrivePullPartialFailure asserts that err is the typed partial-failure
// exit signal +pull returns on any item-level failure. The structured
// {summary, items, note} payload rides on stdout as an ok:false envelope via
// runtime.OutPartialFailure (in alignment with +push/+sync), so this helper
// only checks the exit-code signal; callers read the payload from stdout via
// splitDrivePullStdout.
func assertDrivePullPartialFailure(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected partial_failure ExitError, got nil")
t.Fatal("expected partial-failure exit signal, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI)
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil {
t.Fatalf("ExitError.Detail must be set on partial_failure")
}
if exitErr.Detail.Type != "partial_failure" {
t.Errorf("error.type = %q, want partial_failure", exitErr.Detail.Type)
}
return exitErr
}
// splitDrivePullDetail extracts the {summary, items[]} payload from the
// ExitError detail. We round-trip through JSON so test assertions don't
// depend on the concrete map types the production code happens to use.
func splitDrivePullDetail(t *testing.T, exitErr *output.ExitError) (map[string]interface{}, []map[string]interface{}) {
// splitDrivePullStdout extracts the {summary, items[]} payload from the
// stdout envelope written by runtime.Out. We round-trip through JSON so test
// assertions don't depend on the concrete map types the production code
// happens to use.
func splitDrivePullStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
t.Helper()
raw, err := json.Marshal(exitErr.Detail.Detail)
if err != nil {
t.Fatalf("marshal detail: %v", err)
var envelope struct {
Data struct {
Summary map[string]interface{} `json:"summary"`
Items []map[string]interface{} `json:"items"`
} `json:"data"`
}
var got struct {
Summary map[string]interface{} `json:"summary"`
Items []map[string]interface{} `json:"items"`
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal detail: %v\nraw=%s", err, string(raw))
if envelope.Data.Summary == nil {
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
}
if got.Summary == nil {
t.Fatalf("error.detail missing summary; raw=%s", string(raw))
}
return got.Summary, got.Items
return envelope.Data.Summary, envelope.Data.Items
}
// drivePullStdoutNote extracts the partial-failure "note" guidance from the
// stdout envelope. The human-readable note that used to live in the
// partial_failure ExitError message now rides on stdout alongside the
// summary + items payload.
func drivePullStdoutNote(t *testing.T, stdout []byte) string {
t.Helper()
var envelope struct {
Data struct {
Note string `json:"note"`
} `json:"data"`
}
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
return envelope.Data.Note
}

View File

@@ -5,7 +5,6 @@ package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -19,6 +18,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -112,26 +112,26 @@ var DrivePush = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-remote requires --yes (high-risk: deletes Drive files absent locally)").WithParam("--yes")
}
// Conditional scope pre-check: when --delete-remote --yes is set, the
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
@@ -185,11 +185,11 @@ var DrivePush = common.Shortcut{
// FileIO.Open's SafeInputPath check still accepts.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
@@ -217,7 +217,7 @@ var DrivePush = common.Shortcut{
// reruns.
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
var uploaded, skipped, failed, deletedRemote int
@@ -374,7 +374,7 @@ var DrivePush = common.Shortcut{
}
}
runtime.Out(map[string]interface{}{
payload := map[string]interface{}{
"summary": map[string]interface{}{
"uploaded": uploaded,
"skipped": skipped,
@@ -382,15 +382,15 @@ var DrivePush = common.Shortcut{
"deleted_remote": deletedRemote,
},
"items": items,
}, nil)
// Bump the exit code on any item-level failure (upload, overwrite,
// folder, or delete) so callers / scripts / agents can react. The
// summary + items[] envelope was just written to stdout via Out(),
// so ErrBare here only affects the exit code — the structured
// per-item context is still in the stdout JSON.
if failed > 0 {
return output.ErrBare(output.ExitAPI)
}
// On any item-level failure (upload, overwrite, folder, or delete) the
// command reports a partial failure: the summary + per-item items[] stay
// machine-readable on stdout (ok:false) and the process exits non-zero,
// so callers / scripts / agents can react.
if failed > 0 {
return runtime.OutPartialFailure(payload, nil)
}
runtime.Out(payload, nil)
return nil
},
}
@@ -466,7 +466,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
return nil
})
if err != nil {
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
return nil, nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
}
dirs := make([]string, 0, len(dirsSet))
for d := range dirsSet {
@@ -543,7 +543,7 @@ func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
}
remoteFiles[rel] = chosen
default:
return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
return nil, nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remoteFolders, fileGroups, nil
@@ -567,7 +567,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
return "", err
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
@@ -581,7 +581,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "create_folder for %q returned no folder token", relDir)
}
folderCache[relDir] = token
return token, nil
@@ -617,7 +617,7 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
f, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", "", common.WrapInputStatError(err)
return "", "", driveInputStatError(err)
}
defer f.Close()
@@ -644,27 +644,22 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file
if errors.As(err, &exitErr) {
return "", "", err
}
return "", "", output.ErrNetwork("upload failed: %v", err)
return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
// Extract the token before the larkCode check: the backend can produce
// a partial-success response (code != 0 alongside a non-empty
// data.file_token) where bytes have already landed under that token.
// Returning "" here would force the caller to fall back to
// ClassifyAPIResponse returns the data even on a non-zero code, so the
// token is available on a partial-success response (code != 0 alongside a
// non-empty data.file_token) where bytes have already landed under that
// token. Returning "" would force the caller to fall back to
// entry.FileToken and silently lose the token Drive actually used,
// defeating the overwrite-error token-stability handling in Execute.
data, _ := result["data"].(map[string]interface{})
data, err := runtime.ClassifyAPIResponse(apiResp)
token := common.GetString(data, "file_token")
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
if err != nil {
return token, "", err
}
if token == "" {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
version := common.GetString(data, "version")
if version == "" {
@@ -677,7 +672,7 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file
// deployed backend hasn't shipped the field yet we surface the gap
// rather than report a phantom success — callers can downgrade to
// --if-exists=skip in the meantime.
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
return token, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
}
return token, version, nil
}
@@ -692,7 +687,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
if existingToken != "" {
prepareBody["file_token"] = existingToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
@@ -701,7 +696,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
blockNum := int(common.GetFloat(prepareResult, "block_num"))
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return "", output.Errorf(output.ExitAPI, "api_error",
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
@@ -717,7 +712,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
// one Open + Close + path-validation per block).
partFile, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", driveInputStatError(err)
}
defer partFile.Close()
@@ -744,21 +739,16 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
if errors.As(doErr, &exitErr) {
return "", doErr
}
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
@@ -767,7 +757,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
}
token := common.GetString(finishResult, "file_token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
}
return token, nil
}
@@ -776,7 +766,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
// never reached here because --delete-remote only iterates the type=file
// subset of the remote listing.
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
_, err := runtime.CallAPI(
_, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
map[string]interface{}{"type": driveTypeFile},

View File

@@ -871,21 +871,19 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
"--if-exists", "overwrite",
"--as", "bot",
}, f, stdout)
// Item-level failures bump the exit code via output.ErrBare(ExitAPI),
// preserving the structured items[] envelope on stdout. Older behavior
// was to silently return nil; the assertion below pins the new contract.
// Item-level failures report a partial failure: an ok:false items[]
// envelope on stdout + a non-zero exit via the partial-failure signal.
// Older behavior was to silently return nil; the assertion below pins
// the new contract.
if err == nil {
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, exitErr.Code)
}
if exitErr.Detail != nil {
t.Errorf("ErrBare should carry no Detail (the items[] envelope already covered the per-item error), got: %#v", exitErr.Detail)
if pfErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
}
out := stdout.String()
@@ -959,12 +957,19 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
if err == nil {
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI from output.ExitError, got %T %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
}
out := stdout.String()
// Partial failure reports an ok:false result envelope on stdout (not a
// misleading ok:true) while still carrying BOTH the succeeded and failed
// items — consistent with the pre-change payload. The failed side is
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
if !strings.Contains(out, `"ok": false`) {
t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out)
}
if !strings.Contains(out, `"failed": 1`) {
t.Errorf("expected failed=1, got: %s", out)
}
@@ -1042,9 +1047,9 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected non-zero exit on overwrite failure, got nil\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI ExitError, got %v", err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %v", err)
}
out := stdout.String()
@@ -1065,7 +1070,7 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) {
// TestDrivePushExitsZeroOnCleanRun pins the inverse: a successful run
// with no failures must NOT bump the exit code. Without this the
// ErrBare-on-failure path could regress to "always non-zero" silently.
// partial-failure path could regress to "always non-zero" silently.
func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -6,7 +6,6 @@ package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
@@ -15,6 +14,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -219,13 +219,13 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec {
// that depends on the combination of flag values.
func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.Time) (map[string]interface{}, []string, error) {
if spec.Mine && len(spec.CreatorIDs) > 0 {
return nil, nil, output.ErrValidation("cannot combine --mine and --creator-ids")
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids")
}
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
return nil, nil, output.ErrValidation("cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
}
if spec.Mine && userOpenID == "" {
return nil, nil, output.ErrValidation("--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config")
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine")
}
if err := validateDocTypes(spec.DocTypes); err != nil {
@@ -337,7 +337,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, output.ErrValidation("--page-size must be a number, got %q", raw)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be a number, got %q", raw).WithParam("--page-size")
}
if n <= 0 {
return 15, nil
@@ -355,23 +355,23 @@ func parseDriveSearchPageSize(raw string) (int, error) {
func validateDriveSearchIDs(spec driveSearchSpec) error {
for _, id := range spec.CreatorIDs {
if _, err := common.ValidateUserID(id); err != nil {
return output.ErrValidation("--creator-ids %q: %s", id, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
}
}
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
return output.ErrValidation("--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
}
for _, id := range spec.ChatIDs {
if _, err := common.ValidateChatID(id); err != nil {
return output.ErrValidation("--chat-ids %q: %s", id, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
}
}
if n := len(spec.SharerIDs); n > driveSearchMaxSharerIDs {
return output.ErrValidation("--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
}
for _, id := range spec.SharerIDs {
if _, err := common.ValidateUserID(id); err != nil {
return output.ErrValidation("--sharer-ids %q: %s", id, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
}
}
return nil
@@ -382,7 +382,7 @@ func validateDocTypes(values []string) error {
// values are already upper-cased by readDriveSearchSpec; compare as-is
// so the filter we emit to the server matches what we validated.
if _, ok := driveSearchDocTypeSet[v]; !ok {
return output.ErrValidation("--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v).WithParam("--doc-types")
}
}
return nil
@@ -417,13 +417,13 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error)
}
sinceUnix, err := parseTimeValue(spec.OpenedSince, now)
if err != nil {
return "", output.ErrValidation("invalid --opened-since %q: %s", spec.OpenedSince, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-since %q: %s", spec.OpenedSince, err).WithParam("--opened-since")
}
var untilUnix int64
if spec.OpenedUntil != "" {
untilUnix, err = parseTimeValue(spec.OpenedUntil, now)
if err != nil {
return "", output.ErrValidation("invalid --opened-until %q: %s", spec.OpenedUntil, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-until %q: %s", spec.OpenedUntil, err).WithParam("--opened-until")
}
} else {
untilUnix = now.Unix()
@@ -440,7 +440,7 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error)
}
maxSecs := int64(driveSearchMaxOpenedSpanDays) * 24 * 3600
if spanSecs > maxSecs {
return "", output.ErrValidation(
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--opened-* window spans %d days, exceeds the %d-day (1-year) maximum; narrow the range or run multiple queries",
spanSecs/86400, driveSearchMaxOpenedSpanDays,
)
@@ -505,7 +505,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i
if since != "" {
unix, err := parseTimeValue(since, now)
if err != nil {
return nil, nil, output.ErrValidation("invalid --%s-since %q: %s", timeDimCLIName(key), since, err)
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-since %q: %s", timeDimCLIName(key), since, err).WithParam(fmt.Sprintf("--%s-since", timeDimCLIName(key)))
}
if hourAggregated && unix%3600 != 0 {
snapped := floorHour(unix)
@@ -517,7 +517,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i
if until != "" {
unix, err := parseTimeValue(until, now)
if err != nil {
return nil, nil, output.ErrValidation("invalid --%s-until %q: %s", timeDimCLIName(key), until, err)
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-until %q: %s", timeDimCLIName(key), until, err).WithParam(fmt.Sprintf("--%s-until", timeDimCLIName(key)))
}
if hourAggregated && unix%3600 != 0 {
snapped := ceilHour(unix)
@@ -571,7 +571,7 @@ var driveSearchRelativeRe = regexp.MustCompile(`^(\d+)([dmy])$`)
func parseTimeValue(input string, now time.Time) (int64, error) {
s := strings.TrimSpace(input)
if s == "" {
return 0, fmt.Errorf("empty value")
return 0, fmt.Errorf("empty value") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
}
if m := driveSearchRelativeRe.FindStringSubmatch(s); m != nil {
@@ -616,34 +616,27 @@ func parseTimeValue(input string, now time.Time) (int64, error) {
}
}
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds")
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
}
func callDriveSearchAPI(runtime *common.RuntimeContext, reqBody map[string]interface{}) (map[string]interface{}, error) {
data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
if err != nil {
return nil, enrichDriveSearchError(err)
}
return data, nil
}
// enrichDriveSearchError adds a +search-specific hint for known opaque Lark
// codes; other errors pass through unchanged.
// enrichDriveSearchError adds a +search-specific hint for a known opaque Lark
// code; other errors pass through unchanged. The hint is appended in place on
// the typed Problem, preserving its category / subtype / code / log_id.
func enrichDriveSearchError(err error) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != driveSearchErrUserNotVisible {
return err
}
if exitErr.Detail.Code != driveSearchErrUserNotVisible {
return err
}
detail := *exitErr.Detail
detail.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
return &output.ExitError{
Code: exitErr.Code,
Detail: &detail,
Err: exitErr.Err,
}
p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
return err
}
func cloneDriveSearchFilter(src map[string]interface{}) map[string]interface{} {

View File

@@ -13,6 +13,8 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
)
@@ -258,6 +260,19 @@ func TestValidateDriveSearchIDs(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "--creator-ids") {
t.Fatalf("expected --creator-ids error, got: %v", err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if vErr.Param != "--creator-ids" {
t.Fatalf("Param = %q, want --creator-ids", vErr.Param)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
})
t.Run("bad chat id format", func(t *testing.T) {
@@ -625,51 +640,39 @@ func TestEnrichDriveSearchError(t *testing.T) {
}
})
t.Run("ExitError without Detail passes through", func(t *testing.T) {
t.Run("typed error with non-matching code passes through", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{Code: 1}
if got := enrichDriveSearchError(orig); got != orig {
t.Fatalf("ExitError without Detail should pass through unchanged")
}
})
t.Run("ExitError with non-matching code passes through", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Code: 12345, Message: "other"},
}
orig := errclass.BuildAPIError(
map[string]any{"code": float64(12345), "msg": "other"},
errclass.ClassifyContext{},
)
if got := enrichDriveSearchError(orig); got != orig {
t.Fatalf("non-matching code should pass through unchanged")
}
})
t.Run("matching code rewrites Hint without mutating original", func(t *testing.T) {
t.Run("matching code decorates the typed error's hint in place", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{
Code: driveSearchErrUserNotVisible,
Message: "[99992351] user not visible",
Hint: "",
},
}
orig := errclass.BuildAPIError(
map[string]any{"code": float64(driveSearchErrUserNotVisible), "msg": "[99992351] user not visible"},
errclass.ClassifyContext{},
)
// Terminal decoration of an upstream error: the hint is set in place on
// the existing typed Problem and that same error is returned (no new
// error is constructed).
enriched := enrichDriveSearchError(orig)
eErr, ok := enriched.(*output.ExitError)
if enriched != orig {
t.Fatal("should decorate and return the upstream error, not construct a new one")
}
p, ok := errs.ProblemOf(enriched)
if !ok {
t.Fatalf("expected *output.ExitError, got %T", enriched)
t.Fatalf("expected a typed errs.* error, got %T", enriched)
}
if eErr == orig {
t.Fatal("should return a new ExitError, not mutate the original")
if !strings.Contains(p.Hint, "--creator-ids") {
t.Fatalf("hint should mention --creator-ids, got %q", p.Hint)
}
if orig.Detail.Hint != "" {
t.Fatal("original Detail.Hint must remain unchanged")
}
if !strings.Contains(eErr.Detail.Hint, "--creator-ids") {
t.Fatalf("hint should mention --creator-ids, got %q", eErr.Detail.Hint)
}
if eErr.Detail.Message != orig.Detail.Message {
t.Fatalf("Message should be preserved, got %q", eErr.Detail.Message)
if p.Message != "[99992351] user not visible" {
t.Fatalf("Message should be preserved, got %q", p.Message)
}
})
}
@@ -739,6 +742,18 @@ func TestBuildDriveSearchRequest(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "--mine") {
t.Fatalf("expected exclusion error, got: %v", err)
}
// Mutual-exclusion error: typed validation, but no single attributable
// flag, so Param stays empty.
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if vErr.Param != "" {
t.Fatalf("Param = %q, want empty for mutual-exclusion error", vErr.Param)
}
})
t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) {

View File

@@ -7,7 +7,7 @@ import (
"context"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -36,7 +36,7 @@ var DriveSecureLabelList = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pageSize := runtime.Int("page-size")
if pageSize < 1 || pageSize > 10 {
return output.ErrValidation("--page-size must be between 1 and 10")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 10").WithParam("--page-size")
}
return nil
},
@@ -47,7 +47,7 @@ var DriveSecureLabelList = common.Shortcut{
Params(buildSecureLabelListParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
"/open-apis/drive/v2/my_secure_labels",
buildSecureLabelListParams(runtime),
nil,
@@ -95,7 +95,7 @@ var DriveSecureLabelUpdate = common.Shortcut{
return err
}
body := map[string]interface{}{"id": runtime.Str("label-id")}
data, err := runtime.CallAPI("PATCH",
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,

View File

@@ -17,7 +17,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -75,27 +75,27 @@ var DriveStatus = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
// Path safety (absolute paths, traversal, symlink escape) is enforced
// upfront by the framework helper so the error message references the
// correct flag name; FileIO().Stat below would do the same check, but
// surface --file in its hint.
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
// Conditional scope pre-check: quick mode only compares local mtime with
// Drive modified_time, so it must not be blocked on the download grant.
@@ -144,11 +144,11 @@ var DriveStatus = common.Shortcut{
// only possible under a Validate↔Execute race.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
@@ -263,7 +263,7 @@ func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalF
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
}
return files, nil
}
@@ -276,12 +276,12 @@ func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Ti
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", common.WrapInputStatError(err)
return "", driveInputStatError(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
return "", errs.NewInternalError(errs.SubtypeFileIO, "hash %s: %s", path, err).WithCause(err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
@@ -292,12 +292,12 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
return "", wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
h := sha256.New()
if _, err := io.Copy(h, resp.Body); err != nil {
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
return "", wrapDriveNetworkErr(err, "hash remote %s: %s", common.MaskToken(fileToken), err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}

View File

@@ -822,12 +822,15 @@ func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) {
if err == nil {
t.Fatal("expected walkLocalForStatus() to fail for missing root")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured ExitError, got %T", err)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "io" {
t.Fatalf("expected io error detail, got %#v", exitErr.Detail)
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
if !strings.Contains(err.Error(), "walk") {
t.Fatalf("expected walk-related error, got: %v", err)

View File

@@ -13,7 +13,7 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -72,23 +72,23 @@ var DriveSync = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
return nil
},
@@ -118,15 +118,15 @@ var DriveSync = common.Shortcut{
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir")
}
// --- Phase 1: Compute diff (same logic as +status) ---
@@ -176,18 +176,18 @@ var DriveSync = common.Shortcut{
}
}
if len(typeConflicts) > 0 {
return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
}
// Build the exact remote-file views that later execution will use so the
// diff phase classifies files against the same duplicate-resolution choice.
pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles)
@@ -240,43 +240,19 @@ var DriveSync = common.Shortcut{
conflictResolutions := make(map[string]string, len(modified))
if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil {
return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--on-conflict=ask requires interactive stdin when modified files exist").WithParam("--on-conflict")
}
for _, entry := range modified {
resolved := onConflict
if resolved == driveSyncOnConflictAsk {
resolved, err = driveSyncAskConflict(entry.RelPath, runtime)
if err != nil {
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": 0,
"pushed": 0,
"skipped": 0,
"failed": 1,
},
"items": []driveSyncItem{{
RelPath: entry.RelPath,
FileToken: entry.FileToken,
Action: "failed",
Direction: "conflict",
Error: err.Error(),
}},
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err),
Detail: payload,
},
}
// Phase-1 setup abort: no sync operation ran yet, so this
// is not a batch partial-failure. driveSyncAskConflict
// already returns a typed *errs.ValidationError; propagate
// it unchanged rather than re-wrapping it as a synthetic
// partial_failure payload.
return err
}
}
conflictResolutions[entry.RelPath] = resolved
@@ -521,17 +497,12 @@ var DriveSync = common.Shortcut{
}
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +sync", failed)
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
payload["note"] = fmt.Sprintf("%d item(s) failed during +sync", failed)
}
if failed > 0 {
return runtime.OutPartialFailure(payload, nil)
}
runtime.Out(payload, nil)
return nil
},
@@ -555,7 +526,7 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[
func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath)
if runtime.IO().In == nil {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath).WithParam("--on-conflict")
}
reader, ok := runtime.IO().In.(*bufio.Reader)
if !ok {
@@ -564,12 +535,12 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
}
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read conflict choice for %q: %s", relPath, err).WithParam("--on-conflict")
}
answer := strings.TrimSpace(strings.ToLower(line))
if answer == "" {
if errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath).WithParam("--on-conflict")
}
return driveSyncOnConflictRemoteWins, nil
}
@@ -583,7 +554,7 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
case "r", "remote", "remote-wins":
return driveSyncOnConflictRemoteWins, nil
default:
return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line))
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)).WithParam("--on-conflict")
}
}
@@ -635,16 +606,16 @@ func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderC
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
if info.IsDir() {
return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath)
return errs.NewInternalError(errs.SubtypeFileIO, "original path became a directory during rollback: %s", oldAbsPath)
}
if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err)
return errs.NewInternalError(errs.SubtypeFileIO, "remove partial restored path %q: %s", oldAbsPath, err).WithCause(err)
}
} else if !os.IsNotExist(err) {
return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err)
return errs.NewInternalError(errs.SubtypeFileIO, "stat original path %q during rollback: %s", oldAbsPath, err).WithCause(err)
}
if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err)
return errs.NewInternalError(errs.SubtypeFileIO, "restore renamed local file %q: %s", oldAbsPath, err).WithCause(err)
}
return nil
}

View File

@@ -18,6 +18,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -1434,14 +1435,15 @@ func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) {
if err == nil {
t.Fatalf("expected EOF failure during ask execution\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
// Collecting conflict decisions runs in the Phase-1 setup pass, before
// any sync operation executes, so the EOF abort propagates the typed
// *errs.ValidationError unchanged rather than a synthetic partial_failure.
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
if len(items) == 0 || !strings.Contains(items[0].Error, "stdin reached EOF") {
t.Fatalf("expected failed ask item, got detail: %#v", exitErr.Detail.Detail)
if !strings.Contains(validationErr.Error(), "stdin reached EOF") {
t.Fatalf("expected EOF failure, got: %v", validationErr)
}
data, readErr := os.ReadFile("local/a.txt")
if readErr != nil {
@@ -1503,12 +1505,15 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) {
if err == nil {
t.Fatalf("expected EOF failure during ask planning\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "partial_failure" || !strings.Contains(exitErr.Error(), "stdin reached EOF") {
t.Fatalf("expected planning failure detail mentioning EOF, got: %#v", exitErr.Detail)
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(validationErr.Error(), "stdin reached EOF") {
t.Fatalf("expected planning failure mentioning EOF, got: %v", validationErr)
}
if data, readErr := os.ReadFile("local/a.txt"); readErr != nil || string(data) != "local-a" {
t.Fatalf("a.txt should remain untouched, readErr=%v content=%q", readErr, string(data))
@@ -1706,14 +1711,10 @@ func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected download failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") {
t.Fatalf("expected failed pull item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed pull item, got detail: %#v", stdout.String())
}
}
@@ -1758,14 +1759,10 @@ func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected ensure failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "create parent failed") {
t.Fatalf("expected failed push item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed push item, got detail: %#v", stdout.String())
}
}
@@ -1810,14 +1807,10 @@ func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected upload failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "upload failed") {
t.Fatalf("expected failed upload item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed upload item, got detail: %#v", stdout.String())
}
}
@@ -1875,14 +1868,10 @@ func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "overwrite failed") {
t.Fatalf("expected failed overwrite item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed overwrite item, got detail: %#v", stdout.String())
}
}
@@ -1965,30 +1954,13 @@ func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected keep-both suffix exhaustion error\nstdout: %s", stdout.String())
}
// The error may be a plain ExitError (no Detail.Detail) or a
// partial_failure with items. Either way it must mention the
// suffix exhaustion.
errMsg := err.Error()
// The suffix exhaustion message may be in the top-level error or
// inside a partial_failure detail item. Check both.
foundSuffixError := strings.Contains(errMsg, "could not generate a unique rel_path")
if !foundSuffixError {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
for _, item := range items {
if strings.Contains(item.Error, "could not generate a unique rel_path") {
foundSuffixError = true
break
}
}
if !foundSuffixError {
t.Fatalf("expected suffix exhaustion error, got: %s; detail: %#v", errMsg, exitErr.Detail.Detail)
}
} else {
t.Fatalf("expected suffix exhaustion error, got: %s", errMsg)
}
// The suffix-exhaustion failure is an item-level conflict failure, so
// it surfaces as the partial-failure signal: a typed PartialFailureError
// on the error channel and the ok:false items[] payload (carrying the
// suffix message) on stdout via OutPartialFailure.
assertDriveSyncPartialFailure(t, err)
if !strings.Contains(stdout.String(), "could not generate a unique rel_path") {
t.Fatalf("expected suffix exhaustion error in stdout items, got: %s", stdout.String())
}
}
@@ -2341,14 +2313,10 @@ func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected modified pull failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") {
t.Fatalf("expected failed modified pull item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed modified pull item, got detail: %#v", stdout.String())
}
}
@@ -2411,14 +2379,10 @@ func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) {
if err == nil {
t.Fatalf("expected keep-both rollback failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || !strings.Contains(items[0].Error, "rollback failed") {
t.Fatalf("expected rollback failure in item error, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected rollback failure in item error, got detail: %#v", stdout.String())
}
}
@@ -2500,14 +2464,10 @@ func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || !strings.Contains(items[0].Error, "create parent failed") {
t.Fatalf("expected failed item with create_folder error, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed item with create_folder error, got detail: %#v", stdout.String())
}
}
@@ -2704,7 +2664,7 @@ func TestDriveSyncKeepBothReportsSuffixError(t *testing.T) {
// TestDriveSyncKeepBothRollbackSucceedsOnPullFailure verifies the full
// keep-both rollback path: when the pull download fails after the local
// file has been renamed, the rollback restores the original file and
// the error is reported as a partial_failure.
// the failure is reported via the partial-failure signal.
func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) {
syncTestConfig := &core.CliConfig{
AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -2762,14 +2722,10 @@ func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected keep-both pull failure with rollback\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || !strings.Contains(items[0].Error, "save failed") {
t.Fatalf("expected save failure in item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected save failure in item, got detail: %#v", stdout.String())
}
// Rollback should have restored the original file.
@@ -2978,14 +2934,10 @@ func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 {
t.Fatalf("expected failed item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed item, got detail: %#v", stdout.String())
}
// The reported token should be the new one from the partial-success
// response, not the stale existingToken ("tok_a").
@@ -3095,3 +3047,39 @@ func TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict(t *testing.T) {
t.Fatalf("error should mention local directory, got: %v", err)
}
}
// assertDriveSyncPartialFailure asserts that err is the typed partial-failure
// exit signal +sync returns on any item-level failure. The structured
// {detection, diff, summary, items, note} payload rides on stdout as an
// ok:false envelope via runtime.OutPartialFailure (in alignment with
// +push/+pull), so this helper only checks the exit-code signal; callers read
// the payload from stdout.
func assertDriveSyncPartialFailure(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected partial-failure exit signal, got nil")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
}
// driveSyncStdoutItems extracts the items[] payload from the stdout envelope
// written by runtime.Out. The per-item failure context that used to live in
// the partial_failure ExitError detail now rides on stdout.
func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem {
t.Helper()
var envelope struct {
Data struct {
Items []driveSyncItem `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
return envelope.Data.Items
}

View File

@@ -9,8 +9,8 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,34 +43,34 @@ var DriveTaskResult = common.Shortcut{
"wiki_delete_node": true,
}
if !validScenarios[scenario] {
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
}
// Validate required params based on scenario
switch scenario {
case "import", "export":
if runtime.Str("ticket") == "" {
return output.ErrValidation("--ticket is required for %s scenario", scenario)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ticket is required for %s scenario", scenario).WithParam("--ticket")
}
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
}
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
if runtime.Str("task-id") == "" {
return output.ErrValidation("--task-id is required for %s scenario", scenario)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id")
}
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
}
// For export scenario, file-token is required
if scenario == "export" && runtime.Str("file-token") == "" {
return output.ErrValidation("--file-token is required for export scenario")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required for export scenario").WithParam("--file-token")
}
if scenario == "export" {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
}
@@ -261,9 +261,10 @@ func requireDriveScopes(storedScopes string, required []string) error {
return nil
}
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithMissingScopes(missing...).
WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))
}
func missingDriveScopes(storedScopes string, required []string) []string {
@@ -408,10 +409,10 @@ func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[strin
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err)
return wikiMoveTaskQueryStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "move"},
@@ -426,7 +427,7 @@ func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiM
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
if task == nil {
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return wikiMoveTaskQueryStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
status := wikiMoveTaskQueryStatus{
@@ -490,10 +491,10 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) {
// rather than the per-node array used by wiki move.
func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_space"},
@@ -505,7 +506,7 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
task := common.GetMap(data, "task")
if task == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
resolvedTaskID := common.GetString(task, "task_id")
@@ -558,10 +559,10 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
// keep drive from depending on shortcuts/wiki.
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_node"},
@@ -573,7 +574,7 @@ func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map
task := common.GetMap(data, "task")
if task == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
resolvedTaskID := common.GetString(task, "task_id")

View File

@@ -13,10 +13,12 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -86,6 +88,16 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
})
}
}
@@ -428,6 +440,16 @@ func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
t.Fatalf("expected missing wiki scope error, got %v", err)
}
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if permErr.Subtype != errs.SubtypeMissingScope {
t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope)
}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "wiki:space:read" {
t.Fatalf("MissingScopes = %v, want [wiki:space:read]", permErr.MissingScopes)
}
})
t.Run(scenario+"/accepts wiki scope", func(t *testing.T) {
t.Parallel()
@@ -663,6 +685,19 @@ func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "missing task") {
t.Fatalf("expected missing task error, got %v", err)
}
// A successful API call (code==0) that omits the `task` field is a
// malformed RESPONSE, not a user error: classify as internal /
// invalid_response (exit 5), not an API business error (exit 1).
var iErr *errs.InternalError
if !errors.As(err, &iErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if iErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", iErr.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want ExitInternal (%d)", got, output.ExitInternal)
}
}
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {

View File

@@ -5,7 +5,6 @@ package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -15,6 +14,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -151,7 +151,7 @@ var DriveUpload = common.Shortcut{
info, err := runtime.FileIO().Stat(spec.FilePath)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
fileSize := info.Size()
@@ -194,13 +194,13 @@ var DriveUpload = common.Shortcut{
func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error {
if driveUploadFlagExplicitlyEmpty(runtime, "file-token") {
return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite").WithParam("--file-token")
}
if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token").WithParam("--folder-token")
}
if driveUploadFlagExplicitlyEmpty(runtime, "wiki-token") {
return common.FlagErrorf("--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token").WithParam("--wiki-token")
}
targets := 0
@@ -211,21 +211,21 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe
targets++
}
if targets > 1 {
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token and --wiki-token are mutually exclusive")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
if spec.WikiToken != "" {
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--wiki-token")
}
}
if spec.FileToken != "" {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
}
return nil
@@ -240,7 +240,7 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return driveUploadResult{}, common.WrapInputStatError(err)
return driveUploadResult{}, driveInputStatError(err)
}
defer f.Close()
@@ -265,23 +265,16 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
if errors.As(err, &exitErr) {
return driveUploadResult{}, err
}
return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err)
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return driveUploadResult{}, err
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
return driveUploadResult{
FileToken: fileToken,
@@ -304,7 +297,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if existingFileToken != "" {
prepareBody["file_token"] = existingFileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return driveUploadResult{}, err
}
@@ -316,7 +309,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
blockNum := int(blockNumF)
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error",
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
@@ -334,7 +327,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partFile, err := runtime.FileIO().Open(filePath)
if err != nil {
return driveUploadResult{}, common.WrapInputStatError(err)
return driveUploadResult{}, driveInputStatError(err)
}
fd := larkcore.NewFormdata()
@@ -354,16 +347,11 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if errors.As(err, &exitErr) {
return driveUploadResult{}, err
}
return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return driveUploadResult{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
@@ -374,14 +362,14 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
"upload_id": uploadID,
"block_num": blockNum,
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
if err != nil {
return driveUploadResult{}, err
}
fileToken := common.GetString(finishResult, "file_token")
if fileToken == "" {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
}
return driveUploadResult{

View File

@@ -16,8 +16,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -34,10 +34,10 @@ type driveVersionHistorySpec struct {
func validateDriveNumericValue(value, flagName, valueLabel string) error {
value = strings.TrimSpace(value)
if value == "" {
return output.ErrValidation("%s cannot be empty", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", flagName).WithParam(flagName)
}
if !driveVersionNumberRe.MatchString(value) {
return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be a numeric %s", flagName, valueLabel).WithParam(flagName)
}
return nil
}
@@ -52,10 +52,10 @@ func validateDriveCursorValue(value, flagName string) error {
func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if spec.Limit < 1 || spec.Limit > 200 {
return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --limit %d: must be between 1 and 200", spec.Limit).WithParam("--limit")
}
if spec.Cursor != "" {
if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil {
@@ -180,7 +180,7 @@ var DriveVersionHistory = common.Shortcut{
Cursor: strings.TrimSpace(runtime.Str("cursor")),
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
http.MethodGet,
fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)),
driveVersionHistoryParams(spec),
@@ -214,7 +214,7 @@ type driveVersionGetSpec struct {
func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDriveVersionValue(spec.Version, "--version"); err != nil {
return err
@@ -223,7 +223,7 @@ func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersi
return nil
}
if _, err := validate.SafeOutputPath(spec.Output); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
return nil
}
@@ -299,7 +299,7 @@ var DriveVersionGet = common.Shortcut{
},
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapDriveNetworkErr(err, "download failed: %s", err)
}
defer resp.Body.Close()
@@ -315,10 +315,10 @@ var DriveVersionGet = common.Shortcut{
outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "")
}
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return output.ErrValidation("unsafe output path: %s", resolveErr)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
@@ -326,7 +326,7 @@ var DriveVersionGet = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return driveSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
@@ -354,7 +354,7 @@ type driveVersionMutationSpec struct {
func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
return validateDriveVersionValue(spec.Version, "--version")
}
@@ -392,7 +392,7 @@ var DriveVersionRevert = common.Shortcut{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
if _, err := runtime.CallAPI(
if _, err := runtime.CallAPITyped(
http.MethodPost,
fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)),
nil,
@@ -439,7 +439,7 @@ var DriveVersionDelete = common.Shortcut{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
if _, err := runtime.CallAPI(
if _, err := runtime.CallAPITyped(
http.MethodPost,
fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)),
nil,

View File

@@ -5,14 +5,17 @@ package drive
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -53,6 +56,16 @@ func TestValidateDriveVersionHistorySpec(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
})
}
}
@@ -255,6 +268,13 @@ func TestDriveVersionGetRejectsExistingFileWithoutOverwrite(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "output file already exists") {
t.Fatalf("expected output exists error, got %v", err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument || vErr.Param != "--output" {
t.Fatalf("typed shape = subtype %q param %q, want invalid_argument/--output", vErr.Subtype, vErr.Param)
}
}
func TestDriveVersionGetOverwritesExistingFileWhenRequested(t *testing.T) {

View File

@@ -11,9 +11,10 @@ import (
"path"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -85,7 +86,7 @@ func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext
if pageToken != "" {
params["page_token"] = pageToken
}
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
result, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files", params, nil)
if err != nil {
return nil, err
}
@@ -176,24 +177,27 @@ func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemote
return duplicates
}
// Deprecated: duplicateRemotePathError produces a legacy *output.ExitError
// that predates the typed error contract introduced by errs/. New code MUST
// NOT use it — duplicate-path signals should move to a typed
// *errs.ValidationError (with duplicates metadata as a typed extension
// field) when the drive shortcut migrates to typed errors. This helper is
// retained only while existing call sites are migrated; it will be removed
// once they have moved to the typed surface.
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "duplicate_remote_path",
Message: "multiple Drive entries map to the same rel_path",
Detail: map[string]interface{}{
"duplicates_remote": duplicates,
},
},
// duplicateRemotePathError reports that multiple Drive entries resolve to the
// same rel_path. Each colliding rel_path becomes one InvalidParam whose Name is
// the rel_path and whose Reason enumerates the colliding entries (type +
// file_token), so an AI agent reading the typed envelope can identify exactly
// which Drive objects collide without re-listing the folder.
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) error {
params := make([]errs.InvalidParam, 0, len(duplicates))
for _, d := range duplicates {
descriptions := make([]string, 0, len(d.Entries))
for _, entry := range d.Entries {
descriptions = append(descriptions, fmt.Sprintf("%s %s", entry.Type, entry.FileToken))
}
params = append(params, errs.InvalidParam{
Name: d.RelPath,
Reason: fmt.Sprintf("%d Drive entries collide here: %s", len(d.Entries), strings.Join(descriptions, ", ")),
})
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%d rel_path(s) map to multiple Drive entries", len(duplicates)).
WithHint("resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help").
WithParams(params...)
}
const (
@@ -300,7 +304,7 @@ func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
if len(files) == 0 {
return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy)
return driveRemoteEntry{}, errs.NewInternalError(errs.SubtypeUnknown, "no Drive entries available for strategy %q", strategy)
}
candidates := append([]driveRemoteEntry(nil), files...)
sortRemoteFiles(candidates, strategy)
@@ -385,7 +389,7 @@ func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[st
return candidate, nil
}
}
return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
return "", errs.NewInternalError(errs.SubtypeUnknown, "could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
}
// joinRelDrive joins a rel_path base with an entry name using "/".

View File

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

View File

@@ -121,35 +121,19 @@ var SlidesCreate = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
content := buildPresentationXML(title)
slidesStr := runtime.Str("slides")
// Step 1: Create presentation
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
)
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
if revisionID > 0 {
result["revision_id"] = revisionID
}
// Step 2: Add slides if provided
@@ -198,6 +182,9 @@ var SlidesCreate = common.Shortcut{
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
@@ -205,34 +192,7 @@ var SlidesCreate = common.Shortcut{
}
}
// Fetch presentation URL via drive meta (best-effort)
if metaData, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": presentationID,
"doc_type": "slides",
},
},
"with_url": true,
},
); err == nil {
metas := common.GetSlice(metaData, "metas")
if len(metas) > 0 {
if meta, ok := metas[0].(map[string]interface{}); ok {
if url := common.GetString(meta, "url"); url != "" {
result["url"] = url
}
}
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
@@ -259,6 +219,41 @@ func buildPresentationXML(title string) string {
)
}
func createEmptyPresentation(runtime *common.RuntimeContext, title string) (string, int, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": buildPresentationXML(title),
},
},
)
if err != nil {
return "", 0, err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return "", 0, output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
revisionID := 0
if rev := common.GetFloat(data, "revision_id"); rev > 0 {
revisionID = int(rev)
}
return presentationID, revisionID, nil
}
func fillPresentationResult(runtime *common.RuntimeContext, presentationID string, result map[string]interface{}) {
if url, err := common.FetchDriveMetaURL(runtime, presentationID, "slides"); err == nil && url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
}
// uploadSlidesPlaceholders uploads each unique placeholder path against the
// presentation and returns the path→file_token map. The second return value is
// the number of files successfully uploaded before any error, so callers can

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesCreateSVG creates a new Lark Slides presentation from one or more
// SVGlide SVG files by adding each page through the existing XML slide route.
var SlidesCreateSVG = common.Shortcut{
Service: "slides",
Command: "+create-svg",
Description: "Create a Lark Slides presentation from SVG",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{
"slides:presentation:create",
"slides:presentation:write_only",
"docs:document.media:upload",
},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{
Name: "file",
Type: "string_array",
Required: true,
Desc: "SVG file path; repeat for multiple pages",
},
{Name: "assets", Desc: "optional assets.json path mapping SVG @path placeholders to uploaded file tokens"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); err != nil {
return err
}
return validateSVGAssetsPath(runtime, runtime.Str("assets"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
svgs, err := readSVGFiles(runtime, runtime.StrArray("file"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
pages, uploadPaths := dryRunRewriteSVGImagePlaceholders(svgs, assets)
dry := common.NewDryRunAPI()
total := 1 + len(uploadPaths) + len(pages)
descSuffix := ""
if len(uploadPaths) > 0 {
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(uploadPaths))
}
dry.Desc(fmt.Sprintf("Create presentation from %d SVG page(s)%s", len(pages), descSuffix)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
})
for i, path := range uploadPaths {
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
}
slideStepStart := 2 + len(uploadPaths)
for i, page := range pages {
content, injectErr := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if injectErr != nil {
return common.NewDryRunAPI().Set("error", injectErr.Error())
}
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add SVG page %d", slideStepStart+i, total, i+1)).
Params(map[string]interface{}{"revision_id": -1}).
Body(buildCreateSVGBody(content))
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
}
return dry.Set("title", title)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
svgs, err := readSVGFiles(runtime, runtime.StrArray("file"))
if err != nil {
return err
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return err
}
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
return err
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID > 0 {
result["revision_id"] = revisionID
}
pages, uploaded, err := rewriteSVGImagePlaceholders(runtime, presentationID, svgs, assets)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
}
if uploaded > 0 {
result["images_uploaded"] = uploaded
}
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
for i, page := range pages {
content, err := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if err != nil {
return output.Errorf(output.ExitValidation, "validation",
"page %d/%d failed before API call: %v (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
buildCreateSVGBody(content),
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"page %d/%d failed: %v%s (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, formatSVGlideErrorSuffix(err), presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
},
}

View File

@@ -0,0 +1,436 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
const testSVGlidePage1 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="320" height="180"/></svg>`
const testSVGlidePage2 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><p xmlns="http://www.w3.org/1999/xhtml">second</p></foreignObject></svg>`
func TestSlidesCreateSVGMissingFileFlag(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--title", "missing file",
"--as", "user",
})
if err == nil {
t.Fatal("expected missing --file error")
}
if !strings.Contains(err.Error(), "file") {
t.Fatalf("err = %v, want mention of file", err)
}
}
func TestSlidesCreateSVGFileMissing(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "missing.svg",
"--title", "missing svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for missing SVG")
}
if !strings.Contains(err.Error(), "missing.svg") {
t.Fatalf("err = %v, want mention of missing.svg", err)
}
}
func TestSlidesCreateSVGEmptyFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("empty.svg", nil, 0o644); err != nil {
t.Fatalf("write empty.svg: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "empty.svg",
"--title", "empty svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for empty SVG")
}
if !strings.Contains(err.Error(), "empty.svg") || !strings.Contains(err.Error(), "empty") {
t.Fatalf("err = %v, want empty.svg empty-file message", err)
}
}
func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg",
"revision_id": 1,
},
},
})
slideStub1 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
}
slideStub2 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_2", "revision_id": 3}},
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_svg", "https://x.feishu.cn/slides/pres_svg")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "SVG Deck",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_svg" {
t.Fatalf("xml_presentation_id = %v, want pres_svg", data["xml_presentation_id"])
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
if data["revision_id"] != float64(3) {
t.Fatalf("revision_id = %v, want latest revision 3", data["revision_id"])
}
slideIDs, ok := data["slide_ids"].([]interface{})
if !ok || len(slideIDs) != 2 || slideIDs[0] != "slide_1" || slideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"])
}
assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub1, `<rect slide:role="shape" x="80" y="80" width="320" height="180"/>`)
assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub2, `<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80">`)
}
func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg_partial",
"revision_id": 1,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_ok", "revision_id": 2}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{
"code": 400,
"msg": "invalid svg",
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "partial svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected slide create failure")
}
errMsg := err.Error()
for _, want := range []string{"pres_svg_partial", "page 2/2", "1 slide(s) added", "slide_ok"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want mention of %q", err, want)
}
}
}
func TestSlidesCreateSVGFailureExtractsSVGlideMarker(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_marker", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_marker/slide",
Body: map[string]interface{}{
"code": 400,
"msg": `SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`,
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "marker",
"--as", "user",
})
if err == nil {
t.Fatal("expected marker failure")
}
errMsg := err.Error()
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject", "Use supported elements"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want marker field %q", err, want)
}
}
}
func TestSlidesCreateSVGAssetsReplaceImageAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_asset", "https://x.feishu.cn/slides/pres_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
if strings.Contains(content, "@./hero.png") || strings.Contains(content, "xlink:href") {
t.Fatalf("content should canonicalize asset placeholder: %s", content)
}
for _, want := range []string{`href="boxcn_asset"`, `<metadata data-svglide-assets="true">`, `<img src="boxcn_asset" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGNestedImageAssetsReplaceAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><g transform="translate(10 20)"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></g></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_nested_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_nested_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_nested_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_nested_asset", "https://x.feishu.cn/slides/pres_nested_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "nested assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{
`href="boxcn_asset"`,
`<metadata data-svglide-assets="true">`,
`<img src="boxcn_asset" />`,
`<g transform="translate(10 20)">`,
} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
for _, notWant := range []string{`xlink:href`, `@./hero.png`} {
if strings.Contains(content, notWant) {
t.Fatalf("content should not contain %s: %s", notWant, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGUploadsLocalImagesAndInjectsMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" href="@hero.png" x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("hero.png", []byte("png"), 0o644); err != nil {
t.Fatalf("write hero.png: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_upload", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "boxcn_uploaded"}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_upload/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_upload", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_upload", "https://x.feishu.cn/slides/pres_upload")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "upload",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["images_uploaded"] != float64(1) {
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{`href="boxcn_uploaded"`, `<img src="boxcn_uploaded" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
}
func runSlidesCreateSVGShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
SlidesCreateSVG.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func assertSlideCreateBodyContains(t *testing.T, stub *httpmock.Stub, want string) {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v\nraw=%s", err, string(stub.CapturedBody))
}
slide, _ := body["slide"].(map[string]interface{})
content, _ := slide["content"].(string)
if !strings.Contains(content, want) {
t.Fatalf("slide content = %s\nwant to contain %s", content, want)
}
}

View File

@@ -0,0 +1,745 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
maxSVGFileSizeBytes int64 = 2 * 1024 * 1024
svglideSlideNS = "https://slides.bytedance.com/ns"
svglideContractVersion = "svglide-authoring-contract/v1"
)
type RewrittenSVGPage struct {
Content string
Tokens []string
}
var (
svgRootOpenTagRegex = regexp.MustCompile(`(?s)\A(\s*(?:<\?[^?]*(?:\?[^>][^?]*)*\?>\s*)?(?:<!DOCTYPE[^>]*>\s*)?(?:<!--.*?-->\s*)*)<([A-Za-z_][\w.:-]*)((?:\s[^>]*?)?)(/?>)`)
svgImageTagRegex = regexp.MustCompile(`(?is)<image\b[^>]*>`)
svgImageHrefRegex = regexp.MustCompile(`(?is)(^|\s)(xlink:href|href)\s*=\s*(["'])([^"']*)(["'])`)
svgMetadataRegex = regexp.MustCompile(`(?is)<metadata\b[^>]*\bdata-svglide-assets\s*=\s*(["'])true(["'])[^>]*>.*?</metadata>`)
svgMetadataEndRegex = regexp.MustCompile(`(?is)</metadata\s*>`)
svgMetadataImgRegex = regexp.MustCompile(`(?is)<img\b[^>]*\bsrc\s*=\s*(["'])([^"']+)(["'])`)
svgNumberRegex = regexp.MustCompile(`^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px)?$`)
svgPathNumberRegex = regexp.MustCompile(`[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?`)
svgTransformRegex = regexp.MustCompile(`(?is)([a-zA-Z]+)\(([^)]*)\)`)
svgShapeTags = map[string]bool{
"circle": true,
"ellipse": true,
"foreignObject": true,
"line": true,
"path": true,
"rect": true,
}
svgRequiredAttrsByTag = map[string][]string{
"circle": {"cx", "cy", "r"},
"ellipse": {"cx", "cy", "rx", "ry"},
"foreignObject": {"x", "y", "width", "height"},
"image": {"x", "y", "width", "height"},
"line": {"x1", "y1", "x2", "y2"},
"path": {"d"},
"rect": {"x", "y", "width", "height"},
}
svgGeometryAttrsByTag = map[string][]string{
"circle": {"cx", "cy", "r"},
"ellipse": {"cx", "cy", "rx", "ry"},
"foreignObject": {"x", "y", "width", "height"},
"image": {"x", "y", "width", "height"},
"line": {"x1", "y1", "x2", "y2"},
"rect": {"x", "y", "width", "height"},
}
svgContainerTags = map[string]bool{
"g": true,
"svg": true,
}
svgIgnoredSubtreeTags = map[string]bool{
"defs": true,
"style": true,
}
)
type svgValidationMode int
const (
svgValidationDescend svgValidationMode = iota
svgValidationSkipSubtree
svgValidationStop
)
func validateSVGFileInputs(runtime *common.RuntimeContext, paths []string) error {
if len(paths) == 0 {
return common.FlagErrorf("--file is required")
}
for _, path := range paths {
if strings.TrimSpace(path) == "" {
return common.FlagErrorf("--file cannot be empty")
}
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return common.WrapInputStatError(err, fmt.Sprintf("--file %s: file not found", path))
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("--file %s: must be a regular file", path)
}
if stat.Size() == 0 {
return output.ErrValidation("--file %s: SVG file is empty", path)
}
if stat.Size() > maxSVGFileSizeBytes {
return output.ErrValidation("--file %s: SVG file size %s exceeds %s limit",
path, common.FormatSize(stat.Size()), common.FormatSize(maxSVGFileSizeBytes))
}
}
return nil
}
func readSVGFiles(runtime *common.RuntimeContext, paths []string) ([]string, error) {
svgs := make([]string, 0, len(paths))
for _, path := range paths {
data, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
if err != nil {
return nil, common.WrapInputStatError(err, fmt.Sprintf("--file %s", path))
}
if strings.TrimSpace(string(data)) == "" {
return nil, output.ErrValidation("--file %s: SVG file is empty", path)
}
svg := string(data)
var normalizeErr error
svg, normalizeErr = ensureSVGlideRootContractVersion(svg, path)
if normalizeErr != nil {
return nil, normalizeErr
}
if err := validateSVGlideSVG(svg, path); err != nil {
return nil, err
}
svgs = append(svgs, svg)
}
return svgs, nil
}
func validateSVGlideSVG(svg, path string) error {
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
return output.ErrValidation("--file %s: SVG root element not found", path)
}
tagName := svg[m[4]:m[5]]
if tagName != "svg" {
return output.ErrValidation("--file %s: root element must be non-namespaced <svg>", path)
}
attrs := svg[m[6]:m[7]]
if !hasXMLAttr(attrs, "xmlns:slide", svglideSlideNS) {
return output.ErrValidation("--file %s: root <svg> must declare xmlns:slide=\"%s\"", path, svglideSlideNS)
}
if !hasXMLAttr(attrs, "slide:role", "slide") {
return output.ErrValidation("--file %s: root <svg> must include slide:role=\"slide\"", path)
}
if version := xmlAttrValue(attrs, "slide:contract-version"); version != svglideContractVersion {
return output.ErrValidation("--file %s: root <svg> must include slide:contract-version=\"%s\"", path, svglideContractVersion)
}
if svg[m[8]:m[9]] == "/>" {
return nil
}
return validateSVGlideChildren(svg[m[9]:], path)
}
func ensureSVGlideRootContractVersion(svg, path string) (string, error) {
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
return svg, nil
}
tagName := svg[m[4]:m[5]]
if tagName != "svg" {
return svg, nil
}
attrs := svg[m[6]:m[7]]
version := xmlAttrValue(attrs, "slide:contract-version")
if version == svglideContractVersion {
return svg, nil
}
if strings.TrimSpace(version) != "" {
return "", output.ErrValidation("--file %s: root <svg> must include slide:contract-version=\"%s\"", path, svglideContractVersion)
}
return svg[:m[8]] + fmt.Sprintf(` slide:contract-version="%s"`, svglideContractVersion) + svg[m[8]:], nil
}
func hasXMLAttr(attrs, name, want string) bool {
return xmlAttrValue(attrs, name) == want
}
func xmlAttrValue(attrs, name string) string {
re := regexp.MustCompile(`(?is)(?:^|\s)` + regexp.QuoteMeta(name) + `\s*=\s*(["'])([^"']*)(["'])`)
for _, m := range re.FindAllStringSubmatch(attrs, -1) {
if len(m) >= 4 && m[1] == m[3] {
return m[2]
}
}
return ""
}
func validateSVGlideChildren(svgAfterRootOpen, path string) error {
depth := 0
skipDepth := -1
for i := 0; i < len(svgAfterRootOpen); {
rel := strings.IndexByte(svgAfterRootOpen[i:], '<')
if rel < 0 {
return nil
}
i += rel
switch {
case strings.HasPrefix(svgAfterRootOpen[i:], "<!--"):
end := strings.Index(svgAfterRootOpen[i+4:], "-->")
if end < 0 {
return output.ErrValidation("--file %s: malformed SVG comment", path)
}
i += 4 + end + 3
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "<![CDATA["):
end := strings.Index(svgAfterRootOpen[i+9:], "]]>")
if end < 0 {
return output.ErrValidation("--file %s: malformed SVG CDATA", path)
}
i += 9 + end + 3
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "<?"):
end := strings.Index(svgAfterRootOpen[i+2:], "?>")
if end < 0 {
return output.ErrValidation("--file %s: malformed SVG processing instruction", path)
}
i += 2 + end + 2
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "</"):
end := findSVGTagEnd(svgAfterRootOpen, i)
if end < 0 {
return output.ErrValidation("--file %s: malformed SVG closing tag", path)
}
name := parseSVGClosingTagName(svgAfterRootOpen[i+2 : end])
if depth == 0 && name == "svg" {
return nil
}
if depth > 0 {
depth--
}
if skipDepth >= 0 && depth < skipDepth {
skipDepth = -1
}
i = end + 1
continue
case strings.HasPrefix(svgAfterRootOpen[i:], "<!"):
end := findSVGTagEnd(svgAfterRootOpen, i)
if end < 0 {
return output.ErrValidation("--file %s: malformed SVG declaration", path)
}
i = end + 1
continue
}
end := findSVGTagEnd(svgAfterRootOpen, i)
if end < 0 {
return output.ErrValidation("--file %s: malformed SVG element", path)
}
name, attrs, selfClosing := parseSVGStartTag(svgAfterRootOpen[i+1 : end])
if name == "" {
i = end + 1
continue
}
if skipDepth < 0 {
mode, err := validateSVGlideElement(path, name, attrs)
if err != nil {
return err
}
if mode == svgValidationSkipSubtree && !selfClosing {
skipDepth = depth + 1
}
}
if !selfClosing {
depth++
}
i = end + 1
}
return output.ErrValidation("--file %s: malformed SVG root: missing </svg>", path)
}
func findSVGTagEnd(svg string, start int) int {
var quote byte
for i := start + 1; i < len(svg); i++ {
c := svg[i]
if quote != 0 {
if c == quote {
quote = 0
}
continue
}
if c == '"' || c == '\'' {
quote = c
continue
}
if c == '>' {
return i
}
}
return -1
}
func parseSVGClosingTagName(raw string) string {
raw = strings.TrimSpace(raw)
for i, r := range raw {
if r == '>' || r == '/' || isXMLSpace(r) {
return raw[:i]
}
}
return raw
}
func parseSVGStartTag(raw string) (name, attrs string, selfClosing bool) {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "/") {
return "", "", false
}
if strings.HasSuffix(raw, "/") {
selfClosing = true
raw = strings.TrimSpace(strings.TrimSuffix(raw, "/"))
}
nameEnd := len(raw)
for i, r := range raw {
if isXMLSpace(r) || r == '/' {
nameEnd = i
break
}
}
name = raw[:nameEnd]
attrs = strings.TrimSpace(raw[nameEnd:])
return name, attrs, selfClosing
}
func isXMLSpace(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
}
func validateSVGlideElement(path, tagName, attrs string) (svgValidationMode, error) {
if svgIgnoredSubtreeTags[tagName] {
return svgValidationSkipSubtree, nil
}
if tagName == "metadata" && hasXMLAttr(attrs, "data-svglide-assets", "true") {
return svgValidationSkipSubtree, nil
}
if err := validateSVGlideTransform(path, tagName, attrs); err != nil {
return svgValidationStop, err
}
if svgContainerTags[tagName] {
return svgValidationDescend, nil
}
role := xmlAttrValue(attrs, "slide:role")
if role == "" {
return svgValidationStop, output.ErrValidation("--file %s: <%s> must include slide:role=\"shape\" or slide:role=\"image\" for SVGlide", path, tagName)
}
switch role {
case "shape":
if !svgShapeTags[tagName] {
return svgValidationStop, output.ErrValidation("--file %s: <%s slide:role=\"shape\"> is not supported by SVGlide; use rect, ellipse, circle, line, path, or foreignObject", path, tagName)
}
if tagName == "foreignObject" && !hasXMLAttr(attrs, "slide:shape-type", "text") {
return svgValidationStop, output.ErrValidation("--file %s: <foreignObject slide:role=\"shape\"> must include slide:shape-type=\"text\"", path)
}
if err := validateSVGlideRequiredAttrs(path, tagName, role, attrs); err != nil {
return svgValidationStop, err
}
return svgValidationSkipSubtree, nil
case "image":
if tagName != "image" {
return svgValidationStop, output.ErrValidation("--file %s: <%s slide:role=\"image\"> is not supported by SVGlide; use <image>", path, tagName)
}
href := xmlAttrValue(attrs, "href")
if href == "" {
href = xmlAttrValue(attrs, "xlink:href")
}
if href == "" {
return svgValidationStop, output.ErrValidation("--file %s: <image slide:role=\"image\"> must include href", path)
}
if isExternalSVGHref(href) {
return svgValidationStop, output.ErrValidation("--file %s: <image slide:role=\"image\"> must not use external http(s) or data href; download the image and use href=\"@./path\" or provide a file token", path)
}
if err := validateSVGlideRequiredAttrs(path, tagName, role, attrs); err != nil {
return svgValidationStop, err
}
return svgValidationSkipSubtree, nil
default:
return svgValidationStop, output.ErrValidation("--file %s: <%s> has unsupported slide:role=%q; use \"shape\" or \"image\"", path, tagName, role)
}
}
func validateSVGlideRequiredAttrs(path, tagName, role, attrs string) error {
for _, attr := range svgRequiredAttrsByTag[tagName] {
if strings.TrimSpace(xmlAttrValue(attrs, attr)) == "" {
return output.ErrValidation("--file %s: <%s slide:role=\"%s\"> missing required attribute %q for SVGlide", path, tagName, role, attr)
}
}
for _, attr := range svgGeometryAttrsByTag[tagName] {
value := xmlAttrValue(attrs, attr)
if !isSVGlideNumber(value) {
return output.ErrValidation("--file %s: <%s slide:role=\"%s\"> attribute %q must be a number or px length, got %q", path, tagName, role, attr, value)
}
}
if tagName == "path" {
if err := validateSVGlidePathData(path, attrs); err != nil {
return err
}
}
return nil
}
func isSVGlideNumber(value string) bool {
value = strings.TrimSpace(value)
return value != "" && svgNumberRegex.MatchString(value)
}
func validateSVGlideTransform(path, tagName, attrs string) error {
transform := strings.TrimSpace(xmlAttrValue(attrs, "transform"))
if transform == "" {
return nil
}
for _, m := range svgTransformRegex.FindAllStringSubmatch(transform, -1) {
if len(m) < 3 {
continue
}
fn := strings.TrimSpace(m[1])
for _, arg := range strings.FieldsFunc(m[2], func(r rune) bool {
return r == ',' || isXMLSpace(r)
}) {
arg = strings.TrimSpace(arg)
if arg == "" {
continue
}
if !isSVGlideNumber(arg) {
return output.ErrValidation("--file %s: <%s> transform %s() argument must be a number or px length, got %q", path, tagName, fn, arg)
}
}
}
return nil
}
func validateSVGlidePathData(path, attrs string) error {
d := strings.TrimSpace(xmlAttrValue(attrs, "d"))
withoutNumbers := svgPathNumberRegex.ReplaceAllString(d, "")
hasCommand := false
for _, r := range withoutNumbers {
switch {
case r == ',' || isXMLSpace(r):
continue
case strings.ContainsRune("MLHVZCQmlhvzcq", r):
hasCommand = true
default:
return output.ErrValidation("--file %s: <path slide:role=\"shape\"> unsupported path command or character %q; use only M/L/H/V/C/Q/Z commands", path, string(r))
}
}
if !hasCommand {
return output.ErrValidation("--file %s: <path slide:role=\"shape\"> attribute \"d\" must include at least one M/L/H/V/C/Q/Z path command", path)
}
return nil
}
func isExternalSVGHref(value string) bool {
lower := strings.ToLower(strings.TrimSpace(value))
return strings.HasPrefix(lower, "http://") ||
strings.HasPrefix(lower, "https://") ||
strings.HasPrefix(lower, "data:")
}
func parseSVGAssets(runtime *common.RuntimeContext, path string) (map[string]string, error) {
if strings.TrimSpace(path) == "" {
return nil, nil
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
if err != nil {
return nil, common.WrapInputStatError(err, fmt.Sprintf("--assets %s", path))
}
var assets map[string]string
if err := json.Unmarshal(data, &assets); err != nil {
return nil, output.ErrValidation("--assets %s: invalid JSON object: %v", path, err)
}
for k, v := range assets {
if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" {
return nil, output.ErrValidation("--assets %s: keys and file tokens must be non-empty strings", path)
}
}
return assets, nil
}
func validateSVGAssetsPath(runtime *common.RuntimeContext, path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return common.WrapInputStatError(err, fmt.Sprintf("--assets %s: file not found", path))
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("--assets %s: must be a regular file", path)
}
if stat.Size() == 0 {
return output.ErrValidation("--assets %s: file is empty", path)
}
return nil
}
func rewriteSVGImagePlaceholders(runtime *common.RuntimeContext, presentationID string, svgs []string, assets map[string]string) ([]RewrittenSVGPage, int, error) {
paths := extractSVGImagePlaceholderPaths(svgs, assets)
localTokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, paths)
if err != nil {
return nil, uploaded, err
}
tokens := mergedSVGAssetTokens(assets, localTokens)
pages := make([]RewrittenSVGPage, 0, len(svgs))
for _, svg := range svgs {
content, usedTokens := rewriteSVGImagePlaceholdersWithTokens(svg, tokens)
pages = append(pages, RewrittenSVGPage{Content: content, Tokens: usedTokens})
}
return pages, uploaded, nil
}
func dryRunRewriteSVGImagePlaceholders(svgs []string, assets map[string]string) ([]RewrittenSVGPage, []string) {
paths := extractSVGImagePlaceholderPaths(svgs, assets)
localTokens := make(map[string]string, len(paths))
for _, path := range paths {
localTokens[path] = "<uploaded_file_token:" + filepath.Base(path) + ">"
}
tokens := mergedSVGAssetTokens(assets, localTokens)
pages := make([]RewrittenSVGPage, 0, len(svgs))
for _, svg := range svgs {
content, usedTokens := rewriteSVGImagePlaceholdersWithTokens(svg, tokens)
pages = append(pages, RewrittenSVGPage{Content: content, Tokens: usedTokens})
}
return pages, paths
}
func mergedSVGAssetTokens(assets, localTokens map[string]string) map[string]string {
tokens := map[string]string{}
for k, v := range assets {
key := strings.TrimSpace(k)
token := strings.TrimSpace(v)
if strings.HasPrefix(key, "@") {
key = strings.TrimSpace(strings.TrimPrefix(key, "@"))
}
if key != "" && token != "" {
tokens[key] = token
}
}
for k, v := range localTokens {
tokens[k] = v
}
return tokens
}
func extractSVGImagePlaceholderPaths(svgs []string, assets map[string]string) []string {
var paths []string
seen := map[string]bool{}
for _, svg := range svgs {
for _, tag := range svgImageTagRegex.FindAllString(svg, -1) {
for _, m := range svgImageHrefRegex.FindAllStringSubmatch(tag, -1) {
if len(m) < 6 || m[3] != m[5] || !strings.HasPrefix(m[4], "@") {
continue
}
path := strings.TrimSpace(strings.TrimPrefix(m[4], "@"))
if path == "" || seen[path] || svgAssetTokenForPath(assets, path) != "" {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
func rewriteSVGImagePlaceholdersWithTokens(svg string, tokens map[string]string) (string, []string) {
var used []string
seen := map[string]bool{}
remember := func(token string) {
if token == "" || seen[token] {
return
}
seen[token] = true
used = append(used, token)
}
out := svgImageTagRegex.ReplaceAllStringFunc(svg, func(tag string) string {
return svgImageHrefRegex.ReplaceAllStringFunc(tag, func(attr string) string {
m := svgImageHrefRegex.FindStringSubmatch(attr)
if len(m) < 6 || m[3] != m[5] {
return attr
}
prefix := m[1]
name := m[2]
value := strings.TrimSpace(m[4])
if strings.HasPrefix(value, "@") {
path := strings.TrimSpace(strings.TrimPrefix(value, "@"))
token := tokens[path]
if token == "" {
return attr
}
remember(token)
return fmt.Sprintf(`%shref="%s"`, prefix, xmlEscape(token))
}
if strings.EqualFold(name, "xlink:href") {
if shouldTreatAsFileToken(value) {
remember(value)
}
return fmt.Sprintf(`%shref="%s"`, prefix, xmlEscape(value))
}
if shouldTreatAsFileToken(value) {
remember(value)
}
return attr
})
})
return out, used
}
func svgAssetTokenForPath(assets map[string]string, path string) string {
if len(assets) == 0 {
return ""
}
if token := strings.TrimSpace(assets["@"+path]); token != "" {
return token
}
return strings.TrimSpace(assets[path])
}
func shouldTreatAsFileToken(value string) bool {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(value, "@") || strings.HasPrefix(value, "#") {
return false
}
lower := strings.ToLower(value)
return !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "data:")
}
func injectSVGTransportAssetMetadata(svg string, tokens []string) (string, error) {
tokens = dedupeStrings(tokens)
if len(tokens) == 0 {
return svg, nil
}
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
if m == nil {
return "", fmt.Errorf("SVG root element not found")
}
tagName := svg[m[4]:m[5]]
if tagName != "svg" {
return "", fmt.Errorf("root element must be <svg>")
}
if existing := svgMetadataRegex.FindStringIndex(svg); existing != nil {
block := svg[existing[0]:existing[1]]
existingTokens := metadataImgTokens(block)
var missing []string
for _, token := range tokens {
if !existingTokens[token] {
missing = append(missing, token)
}
}
if len(missing) == 0 {
return svg, nil
}
addition := renderSVGTransportImgs(missing)
rewritten := svgMetadataEndRegex.ReplaceAllStringFunc(block, func(end string) string {
return addition + end
})
return svg[:existing[0]] + rewritten + svg[existing[1]:], nil
}
metadata := `<metadata data-svglide-assets="true">` + renderSVGTransportImgs(tokens) + `</metadata>`
prefix := svg[:m[8]]
closer := svg[m[8]:m[9]]
after := svg[m[9]:]
if closer == "/>" {
return prefix + ">" + metadata + "</svg>" + after, nil
}
return svg[:m[9]] + metadata + after, nil
}
func metadataImgTokens(metadata string) map[string]bool {
out := map[string]bool{}
for _, m := range svgMetadataImgRegex.FindAllStringSubmatch(metadata, -1) {
if len(m) >= 4 && m[1] == m[3] {
out[m[2]] = true
}
}
return out
}
func renderSVGTransportImgs(tokens []string) string {
var b strings.Builder
for _, token := range tokens {
b.WriteString(`<img src="`)
b.WriteString(xmlEscape(token))
b.WriteString(`" />`)
}
return b.String()
}
func dedupeStrings(in []string) []string {
var out []string
seen := map[string]bool{}
for _, item := range in {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func buildCreateSVGBody(svg string) map[string]interface{} {
return map[string]interface{}{
"slide": map[string]interface{}{"content": svg},
}
}
func extractSVGlideErrorJSON(err error) map[string]interface{} {
if err == nil {
return nil
}
const marker = "SVGLIDE_ERROR_JSON:"
msg := err.Error()
idx := strings.Index(msg, marker)
if idx < 0 {
return nil
}
raw := strings.TrimSpace(msg[idx+len(marker):])
if end := strings.IndexAny(raw, "\r\n"); end >= 0 {
raw = raw[:end]
}
var parsed map[string]interface{}
if json.Unmarshal([]byte(raw), &parsed) != nil {
return nil
}
return parsed
}
func formatSVGlideErrorSuffix(err error) string {
parsed := extractSVGlideErrorJSON(err)
if len(parsed) == 0 {
return ""
}
data, jsonErr := json.Marshal(parsed)
if jsonErr != nil {
return ""
}
return " svglide_error=" + string(data)
}

View File

@@ -0,0 +1,327 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestExtractSVGImagePlaceholderPaths(t *testing.T) {
t.Parallel()
svgs := []string{
`<svg><image slide:role="image" href="@./hero.png"/><a href="@./link.png"/></svg>`,
`<svg><image xlink:href='@./hero.png'/><image href = "@./other.png"/></svg>`,
}
got := extractSVGImagePlaceholderPaths(svgs, map[string]string{"@./other.png": "boxcn_other"})
want := []string{"./hero.png"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestRewriteSVGImagePlaceholdersWithTokens(t *testing.T) {
t.Parallel()
in := `<svg><image slide:role="image" href="@./hero.png"/><image xlink:href='@./logo.png'/><image data-href="@./ignored.png"/><a href="@./link.png">link</a><image href="https://example.com/noop.png"/></svg>`
got, tokens := rewriteSVGImagePlaceholdersWithTokens(in, map[string]string{
"./hero.png": "boxcn_hero",
"./logo.png": "boxcn_logo",
})
for _, want := range []string{`href="boxcn_hero"`, `href="boxcn_logo"`} {
if !strings.Contains(got, want) {
t.Fatalf("rewritten SVG missing %s: %s", want, got)
}
}
if strings.Contains(got, "xlink:href") {
t.Fatalf("rewritten SVG must not retain xlink:href: %s", got)
}
if !strings.Contains(got, `<a href="@./link.png">`) {
t.Fatalf("non-image href should be untouched: %s", got)
}
if !strings.Contains(got, `data-href="@./ignored.png"`) {
t.Fatalf("non-href image attribute should be untouched: %s", got)
}
wantTokens := []string{"boxcn_hero", "boxcn_logo"}
if !reflect.DeepEqual(tokens, wantTokens) {
t.Fatalf("tokens = %v, want %v", tokens, wantTokens)
}
}
func TestInjectSVGTransportAssetMetadata(t *testing.T) {
t.Parallel()
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b", "boxcn_a"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rootIdx := strings.Index(got, "<svg")
metaIdx := strings.Index(got, `<metadata data-svglide-assets="true">`)
if rootIdx < 0 || metaIdx < rootIdx {
t.Fatalf("metadata should be injected inside root <svg>, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should be deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b missing, got: %s", got)
}
}
func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) {
t.Parallel()
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-assets="true"><img src="boxcn_a" /></metadata><image href="boxcn_a"/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Count(got, `<metadata data-svglide-assets="true">`) != 1 {
t.Fatalf("should keep a single transport metadata block, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should remain deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b should be appended, got: %s", got)
}
}
func TestEnsureSVGlideRootContractVersionInjectsMissingVersion(t *testing.T) {
t.Parallel()
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
got, err := ensureSVGlideRootContractVersion(in, "page.svg")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1"`) {
t.Fatalf("contract version missing after normalization: %s", got)
}
if strings.Index(got, `slide:contract-version`) > strings.Index(got, `><rect`) {
t.Fatalf("contract version should be injected on the root open tag: %s", got)
}
if err := validateSVGlideSVG(got, "page.svg"); err != nil {
t.Fatalf("normalized SVG should pass validation: %v", err)
}
}
func TestEnsureSVGlideRootContractVersionRejectsWrongVersion(t *testing.T) {
t.Parallel()
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="old"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
_, err := ensureSVGlideRootContractVersion(in, "page.svg")
if err == nil {
t.Fatal("expected wrong contract-version to fail")
}
if !strings.Contains(err.Error(), `slide:contract-version="svglide-authoring-contract/v1"`) {
t.Fatalf("error = %v, want contract-version guidance", err)
}
}
func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
t.Parallel()
tests := []struct {
name string
svg string
wantErr string
}{
{
name: "supported shape rect",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported image href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported image xlink href before rewrite",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" xlink:href="@./hero.png" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported path commands",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M1e-3 0 L80 0 H120 V40 C120 60 100 80 80 80 Q40 80 20 40 Z" fill="#123456"/></svg>`,
},
{
name: "defs and metadata are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><defs><rect id="r"/></defs><metadata data-svglide-assets="true"><img src="boxcn_img"/></metadata><circle slide:role="shape" cx="50" cy="50" r="20"/></svg>`,
},
{
name: "group container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g fill="#112233" transform="translate(10 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "group container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="shape"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg slide:role="shape" viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "style and nested defs are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.primary{fill:#123456}</style><g><defs><linearGradient id="g"><stop offset="0%" stop-color="#fff"/><stop offset="100%" stop-color="#000"/></linearGradient></defs></g><rect slide:role="shape" class="primary" x="0" y="0" width="100" height="60" fill="url(#g)"/></svg>`,
},
{
name: "filter and shadow styles are preserved",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.card{filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2));box-shadow:0 8px 20px rgba(0,0,0,.18)}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><rect slide:role="shape" class="card" x="0" y="0" width="100" height="60" filter="url(#shadow)"/></svg>`,
},
{
name: "foreignObject XHTML subtree is not role-validated",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml"><span>hello</span></div></foreignObject></svg>`,
},
{
name: "foreignObject XHTML br is allowed",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml">hello<br />world</div></foreignObject></svg>`,
},
{
name: "namespaced root is rejected with precise message",
svg: `<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg:svg>`,
wantErr: `root element must be non-namespaced <svg>`,
},
{
name: "root child missing role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
},
{
name: "group child missing role is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g><rect x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
},
{
name: "unsupported text element remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="shape" x="0" y="20">bad</text></svg>`,
wantErr: `<text slide:role="shape"> is not supported by SVGlide`,
},
{
name: "rect shape requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" height="60"/></svg>`,
wantErr: `<rect slide:role="shape"> missing required attribute "width"`,
},
{
name: "path shape requires d",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" fill="#123456"/></svg>`,
wantErr: `<path slide:role="shape"> missing required attribute "d"`,
},
{
name: "rect rejects percent geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="50%" height="60"/></svg>`,
wantErr: `attribute "width" must be a number or px length`,
},
{
name: "rect rejects calc geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="calc(10px)" y="0" width="100" height="60"/></svg>`,
wantErr: `attribute "x" must be a number or px length`,
},
{
name: "container transform rejects percent argument",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g transform="translate(10% 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `transform translate() argument must be a number or px length`,
},
{
name: "path rejects arc command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "A"`,
},
{
name: "path rejects smooth command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 S10 10 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "S"`,
},
{
name: "plain metadata remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata><desc>not transport metadata</desc></metadata></svg>`,
wantErr: `<metadata> must include slide:role="shape" or slide:role="image"`,
},
{
name: "foreignObject shape requires text type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
wantErr: `<foreignObject slide:role="shape"> must include slide:shape-type="text"`,
},
{
name: "image role must be image tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="image" href="boxcn_img"/></svg>`,
wantErr: `<rect slide:role="image"> is not supported`,
},
{
name: "image requires href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must include href`,
},
{
name: "image requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" height="60"/></svg>`,
wantErr: `<image slide:role="image"> missing required attribute "width"`,
},
{
name: "image rejects external href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="https://images.unsplash.com/photo.jpg" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must not use external http(s) or data href`,
},
{
name: "unsupported role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="decor"/></svg>`,
wantErr: `unsupported slide:role="decor"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateSVGlideSVG(withTestSVGlideContractVersion(tt.svg), "page.svg")
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr)
}
})
}
}
func withTestSVGlideContractVersion(svg string) string {
if strings.Contains(svg, `slide:contract-version=`) {
return svg
}
return strings.Replace(svg, `slide:role="slide"`, `slide:role="slide" slide:contract-version="svglide-authoring-contract/v1"`, 1)
}
func TestExtractSVGlideErrorJSON(t *testing.T) {
t.Parallel()
err := errors.New(`api error: SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`)
got := extractSVGlideErrorJSON(err)
if got["type"] != "svg_validation_error" {
t.Fatalf("type = %v", got["type"])
}
if got["tag_name"] != "foreignObject" {
t.Fatalf("tag_name = %v", got["tag_name"])
}
suffix := formatSVGlideErrorSuffix(err)
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject"} {
if !strings.Contains(suffix, want) {
t.Fatalf("suffix = %q, want %q", suffix, want)
}
}
}

View File

@@ -1,6 +1,6 @@
---
name: lark-slides
version: 1.0.0
version: 1.0.2
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
@@ -15,24 +15,33 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md``svg-protocol.md``svg-visual-recipes.md``svg-aesthetic-review.md` |
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 上传或使用图片 | Preview 阶段优先多用真实图片增强视觉冲击;可先用公开可访问 http(s)/data 图片或本地 `@./path`,来源/授权只 warning 不阻断;正式交付再替换为授权清晰的 file token / 本地资产 | `slides +media-upload`,或 `+create --slides` / `+create-svg``@./path` 占位符 |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — 走 XML 创建/编辑路径时,生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。走 SVG 创建路径(`slides +create-svg`MUST 改读 [svg-protocol.md](references/svg-protocol.md),不要求读取 XML schema。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免**
**CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVGroot `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG**
**CRITICAL — SVGlide deck 页数默认值:当用户要求生成 SVG/SVGlide 幻灯片但未说明页数,或使用“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时默认生成 `10` 页,不要仅因页数缺失而停下来追问。只有用户明确说“一页 / 单页 / onepage / one slide / 只要封面”等单页意图时,才生成 `1` 页;用户给出明确页数时始终服从用户要求。默认 10 页时必须在 `slide_plan.json` 写入 `page_count` 或 `target_slide_count=10`,并包含明确 closing slide。**
**CRITICAL — 高质量 SVG deck 生成时MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md) 和 [svg-visual-recipes.md](references/svg-visual-recipes.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan再为每页选择 `visual_recipe`、声明 `svg_primitives` / `visual_focal_point` / `xml_like_risk`,然后定义布局盒,给 `foreignObject` 文本留足安全高度。生成器必须在写 SVG 前做 preflight-aware 自检:由实际组件 manifest 反推出 primitives按 `content_density_contract` 计数,检查主体元素 safe area / text bbox不要只靠最终 `svg_preflight.py` 兜底。Preview 阶段默认必须使用丰富真实图片资产,并 SHOULD 优先根据用户 query / deck 主题 / 章节标题去网络检索和拉取强相关图片;公开图、场景图、产品图、截图、纹理/材质、图鉴图均可作为占位视觉。版权/授权不作为 preview 阻断,但要在 `asset_contract` 里标记 `retrieval_query`、`source_url` 和 `preview_unverified`;正式交付再替换为授权清晰的本地 `@./path` / file token。相邻页面要显著换版式且 8 页以上至少使用 5 种 visual recipe family如果 agent 支持本地浏览器预览SHOULD 生成并打开 `preview.html`,并按 [svg-aesthetic-review.md](references/svg-aesthetic-review.md) 检查明显视觉问题;调用 API 前必须跑本地 preflight优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
**CRITICAL — SVGlide 高质量生成必须读取 [style-presets.md](references/style-presets.md),并从 [style-presets.json](references/style-presets.json) 选择一个 deck-level `style_preset`。`style_preset` 只表达视觉语言,不替代 `visual_recipe``visual_recipe` 的选择和安全效果边界以 [svg-visual-recipes.md](references/svg-visual-recipes.md) 为准。生成顺序是 semantic plan -> visual_recipe -> style_preset/style_system -> layout boxes -> SVG。每页必须声明 `visual_signature` 和 `svg_effects`,说明这一页相对普通 XML/PPT 模板的 SVG 视觉优势。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML 或 SVGlide SVG。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)SVG 创建前的本地 preflight 优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)SVG 本地预览后按 [svg-aesthetic-review.md](references/svg-aesthetic-review.md) 做审美和重复问题复核**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
@@ -77,7 +86,7 @@ lark-cli auth login --domain slides
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)SVG 创建:[`lark-slides-create-svg.md`](references/lark-slides-create-svg.md)、[`svg-protocol.md`](references/svg-protocol.md)、[`style-presets.md`](references/style-presets.md)、[`svg-visual-recipes.md`](references/svg-visual-recipes.md)、[`svg-aesthetic-review.md`](references/svg-aesthetic-review.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
@@ -99,7 +108,7 @@ lark-cli auth login --domain slides
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
可优先考虑这些页面形态:
@@ -123,7 +132,9 @@ lark-cli auth login --domain slides
- 不要所有页面复用同一种标题 + 三 bullets 版式。
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉
- Preview 阶段不要因为版权/授权缺失而退回纯矢量;推荐先把用户 query、deck 标题和每页章节主题拆成图片检索词,去网络拉取强相关真实图片、网页截图、产品截图或图库图做视觉占位。必须记录 `retrieval_query`、来源 URL或标记 `license=preview_unverified`,并避免误导性商标背书、敏感肖像和明显不适当素材。正式交付时再替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产
- 不要把素材缺失表现为空白图片框;必须先尝试获取或生成可用图片资产。只有用户明确要求纯矢量、网络/权限不可用,或主题确实不适合图片时,才按 `fallback_if_missing` 生成 XML-native 视觉,并在结果中说明。
- Preview/MVP 阶段图片来源/授权/外链问题不作为 `svg_preflight.py` 的 hard blocker但必须保留 warning 并在 live readback 后检查图片是否可见;正式交付仍优先用本地 `@./path` 自动上传或 file token。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
@@ -132,6 +143,7 @@ lark-cli auth login --domain slides
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| AI 生成 SVGlide SVG希望减少 shell XML 转义、按文件逐页创建) | `slides +create-svg --file page1.svg --file page2.svg --title "<标题>"` |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
@@ -152,7 +164,7 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
```text
Step 1: 需求澄清 & 读取知识
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
- 澄清主题、受众、页数、风格;SVGlide 模糊页数按默认 10 页处理,不因页数缺失单独阻塞;模板需求按“模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
@@ -160,10 +172,10 @@ Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 按 slide_plan.json 生成 XML → 创建
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
- XML 路径按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行SVG 路径按 lark-slides-create-svg.md 和 svg-protocol.md 执行,产物是 `.svg` 文件而不是 Slides XML仍复用同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
@@ -259,6 +271,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+create-svg`](references/lark-slides-create-svg.md) | 从一个或多个 SVGlide SVG 文件创建 PPT`--file` 顺序逐页调用现有 `/slide` 路由 |
| [`+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/>`,不改变页序 |
@@ -272,19 +285,20 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加AI SVG 路径使用 `slides +create-svg`,不要把 SVG 塞进 `--slides`
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
8. **Preview 阶段图片要优先丰富,不要纯矢量兜底**XML 路径使用 `<img src="...">`SVG 路径使用 `<image slide:role="image" href="...">`。推荐流程是「从用户 query / 页面主题生成图片检索词 → 网络拉取主题强相关图片 → 存成本地资产 → 用 `slides +media-upload` 上传`+create --slides` / `+create-svg``@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。Preview/MVP 阶段 `svg_preflight.py` 对 http(s) / data 图片、来源/授权不完整只 warning不阻断如果时间紧可先保留公开可访问图片 URL 做视觉验证,并在 `asset_contract` 标记 `retrieval_query``source_url``preview_unverified`。正式交付再统一替换为本地 `@./path` 或 file token。**图片最大 20 MB**slides upload API 不支持分片上传)。
## 权限速查
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload` |
| `slides +create-svg` | `slides:presentation:create`, `slides:presentation:write_only`, `docs:document.media:upload` |
| `slides +media-upload` | `docs:document.media:upload`wiki URL 解析还需 `wiki:node:read` |
| `slides +replace-slide` | `slides:presentation:update`wiki URL 解析还需 `wiki:node:read` |
| `xml_presentations.get` | `slides:presentation:read` |
@@ -293,4 +307,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
| `xml_presentation.slide.get` | `slides:presentation:read` |
| `xml_presentation.slide.replace` | `slides:presentation:update` |
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
> **注意**XML 路径如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准SVG 路径以 [svg-protocol.md](references/svg-protocol.md) 为准。
## SVG 排障
`slides +create-svg` 失败时,优先查看错误中是否包含 `svglide_error` 或服务端 `SVGLIDE_ERROR_JSON:` marker。常见修复
- `svg_validation_error`:按 [svg-protocol.md](references/svg-protocol.md) 修正 root `<svg>``xmlns:slide``slide:role` 或不支持元素。
- 图片不显示:确认 `<image>` 使用 canonical `href="file_token"`,不要保留 `xlink:href`;本地图片用 `href="@./image.png"` 让 CLI 上传,或用 `--assets assets.json` 提供 token 映射。
- 有 file token 仍失败:确认 SVG 内存在 transport metadata`<metadata data-svglide-assets="true"><img src="同一个 file_token" /></metadata>``+create-svg` 会自动注入,手写 SVG 时不要删除。

View File

@@ -0,0 +1,676 @@
# slides +create-svg
从一个或多个 SVGlide SVG 文件创建飞书幻灯片:
```bash
lark-cli slides +create-svg \
--as user \
--title "Demo" \
--file page1.svg \
--file page2.svg
```
## 适用场景
- AI 已经能生成符合 [svg-protocol.md](svg-protocol.md) 的 SVGlide SVG。
- 希望按文件逐页创建,避免把大段 XML/SVG 塞进 shell 参数。
- 需要 SVG 内本地图片占位符自动上传并替换为 file token。
不适用:
- 你只有普通 SVG且没有 `slide:role` 协议标记。
- 复杂普通 SVG 不能直接提交;需要把实际可渲染元素标成 SVGlide role。`g` / 嵌套 `svg` 容器可以保留,但不能代替子元素 role。
- 你需要插入到指定页前MVP 只创建新 presentation 并按顺序追加页面。
## Flags
| Flag | 说明 |
|------|------|
| `--title` | presentation 标题,省略时为 `Untitled` |
| `--file` | SVG 文件路径;可重复,页面顺序就是 flag 顺序 |
| `--assets` | 可选 `assets.json`,把 SVG `@path` 映射到已上传 file token |
| `--dry-run` | 展示创建空白 presentation + N 次 `/slide` 调用,不真实创建 |
## 请求链路
CLI 先创建空白 presentation
```http
POST /open-apis/slides_ai/v1/xml_presentations
```
随后对每个 SVG 文件调用现有 slide create 路由:
```http
POST /open-apis/slides_ai/v1/xml_presentations/{xml_presentation_id}/slide?revision_id=-1
```
body
```json
{
"slide": {
"content": "<svg ...>...</svg>"
}
}
```
不会新增 `/svg_slide` 路由,也不会把 `file_meta_map` 当成 CLI 到服务端的契约。
## 图片处理
SVG 内本地图片写成:
```xml
<image slide:role="image" href="@./hero.png" x="0" y="0" width="320" height="180" />
```
`<image>` 可以位于 `g` / 嵌套 `svg` 容器中CLI 会全局扫描 `<image href="@...">``<image xlink:href="@...">` 并替换为 canonical `href="file_token"`
CLI 会:
1. 上传本地图片到新 presentation。
2.`href="@./hero.png"``xlink:href="@./hero.png"` 替换为 canonical `href="file_token"`
3. 注入 transport metadata`<metadata data-svglide-assets="true"><img src="file_token" /></metadata>`
预上传资源可用 `--assets`
```json
{
"@./hero.png": "boxcn..."
}
```
## 生成质量规则
这些规则用于生成阶段主动规避服务端降级、近似和泛化错误。几何数值、path 命令、role/必填属性、图片 href 等基础约束已由 CLI 强校验;版式、美观和文本溢出仍需要生成器或人工复核。
### 与现有规划层对齐
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。
在通用 plan 字段基础上SVG deck 还应补充这些 SVG 专属字段:
```json
{
"output_mode": "svglide-svg",
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
"style_preset": "raw_grid",
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
"style_system": {
"palette": {
"background": "#F5F5F5",
"text": "#0A0A0A",
"accent": "#F2D4CF"
},
"typography": "strong title, readable native text labels",
"background_strategy": "muted grid panels with one stable background family",
"motif": "dense grid panels with restrained accent labels"
},
"svg_constraints": {
"text_element": "foreignObject slide:role=shape slide:shape-type=text",
"path_commands": "M/L/H/V/C/Q/Z only",
"image_href": "@./path or file token only",
"css": "explicit font-size/font-weight/color/line-height/text-align; no font shorthand"
},
"svg_files": [
{"page": 1, "path": ".lark-slides/plan/<deck-id>/pages/page-001.svg"}
],
"preflight": {
"command": "python3 skills/lark-slides/scripts/svg_preflight.py --plan .lark-slides/plan/<deck-id>/slide_plan.json --input .lark-slides/plan/<deck-id>/pages/page-001.svg",
"status": "pending"
},
"readback_verification": {
"status": "pending",
"checks": ["page_count", "blank_page", "canvas_bounds", "text_overlap", "asset_tokens", "closing_slide"]
}
}
```
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes不要照搬模板 XML也不要读取完整模板 XML。
SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:
```json
{
"page": 3,
"page_type": "content",
"renderer_id": "dashboard_scorecard",
"layout_family": "dashboard",
"visual_recipe": "fake_ui_dashboard",
"visual_intent": "use a product-console dashboard surface to make metrics feel operational",
"visual_focal_point": "central metric card and trend line",
"visual_signature": "fake product console frame + micro chart geometry + status chips",
"svg_effects": ["chart_geometry", "connector_flow", "typography"],
"required_primitives": ["dashboard", "micro_chart"],
"svg_primitives": ["dashboard", "micro_chart", "typography", "geometric_shape"],
"xml_like_risk": "without SVG primitives this page would degrade into three metric cards plus bullets",
"recipe_fallback": "if dashboard micro charts are too dense, keep the fake UI frame and simplify charts to bar-like rects",
"density": "high",
"density_structure": "dashboard with four metric cards, trend line, and source note",
"content_density_contract": "dashboard >= 4 metrics",
"asset_contract": "none_required | {mode: preview|production, retrieval_query, source_type, license, local_path_or_href, usage_page, source_url/generated_by, replacement_required}",
"risk_flags": ["text_overflow", "image_license", "conversion_dasharray"],
"source_status": "source_verified | attachment_missing | user_prompt_only",
"source_policy": "when attachment_missing, show 待从附件补齐 / 来源缺失 and avoid numeric claims",
"layout_guardrails": [
"renderer_id must change actual geometry, not only the name",
"visual_recipe must map to SVGlide-safe primitives present in the SVG source",
"main text and chart labels stay inside safe area",
"dense page uses a structured visual carrier, not a long bullet box",
"avoid XML-like card layout unless the page has real SVG-native visual structure"
]
}
```
### Style Preset Catalog
SVGlide 高质量生成必须先从 [style-presets.json](style-presets.json) 选择一个 deck-level `style_preset`,并把它翻译成 `style_system``style_preset` 不替代 `visual_recipe`:前者定义视觉语言,后者定义页面结构和 SVG-native 表达价值。
生成前还必须读取 [svg-visual-recipes.md](svg-visual-recipes.md)。该文件是当前 CLI 执行链路的短规则入口,负责把研究 catalog 映射成可写入 `slide_plan.json` 的 underscore `visual_recipe` 枚举、安全效果边界和 deck 多样性要求。
生成顺序:
```text
semantic plan
-> visual_recipe
-> style_preset + style_system
-> layout boxes
-> SVG source
-> svg_preflight.py --plan
```
`style_system` 至少包含:
- `palette`: 背景、正文、强调色。
- `typography`: 标题、标签、正文的字号/字重策略。
- `background_strategy`: 全 deck 背景和例外页规则。
- `motif`: 可复用的视觉母题,例如 grid panels、stamp labels、court lanes、riso color plates。
每页必须声明:
- `visual_signature`: 这一页相对普通 XML/PPT 模板的独特 SVG 视觉记忆点。
- `svg_effects`: 真实使用或计划使用的 SVG 表达能力,例如 `path``connector_flow``gradient``texture``chart_geometry``image_overlay`
`svg_preflight.py` 会校验 preset 是否存在、`style_system` 是否完整、可见文本是否泄漏 preset 名称/source token/tool/path以及 declared `svg_effects` 是否能在 SVG source 中命中。
### SVG-native visual recipe catalog
SVG 不是普通矢量图文件的传输外壳。每页都必须选择一个 `visual_recipe`,并在 `svg_primitives` 中声明真实会绘制的 SVGlide-safe primitives。`renderer_id` 负责几何布局命名;`visual_recipe` 负责说明这页为什么值得走 SVG。
本节保留协议内置摘要;实际生成前优先读 [svg-visual-recipes.md](svg-visual-recipes.md),避免把研究文档里的 dotted recipe 名称直接写进运行时 plan。
| `visual_recipe` | 适用页型 | required primitives | forbidden patterns / fallback |
|---|---|---|---|
| `hero_typography` | 封面、章节页、观点页 | `typography`, `geometric_shape` | 不要只写普通标题;大字用 `foreignObject`,描边/裁切感用大字背板、路径轮廓或分层 shape 模拟 |
| `geometric_composition` | 战略框架、阶段划分、版式强分区 | `geometric_shape`, `path` | 不要只堆 3 个矩形卡片;斜切块、多边形全部用 `path` 写,不用 `polygon` |
| `path_flow` | 路线、旅程、流程、增长路径 | `path`, `annotation` | 不依赖 `marker` / `stroke-dasharray`;箭头用显式三角 `path`,虚线用短 line/dot 组合 |
| `infographic_scorecard` | 数据战报、OKR、业务复盘 | `typography`, `micro_chart` | 不要只放大数字;补环形/条形/标尺等微图表,圆环用双层填充圆或 path |
| `icon_capability_map` | 能力地图、模块总览、平台能力 | `icon`, `geometric_shape` | 图标用 SVGlide-safe path/line/rect 组合,不用外链 iconfont 或根级 `<text>` |
| `gradient_depth` | 能力升级、概念页、氛围页 | `gradient`, `geometric_shape` | 渐变只作为层次,不能替代信息结构;关键文字必须有深色承载底 |
| `mask_clip_showcase` | 成果展示、产品/品牌视觉页 | `typography`, `image_overlay` | 不直接依赖 `mask` / `clipPath`;用大字描边、半透明 shape 遮罩、裁切安全区模拟 |
| `technical_texture` | 技术架构封面、工程系统页 | `texture`, `path` | 不用 `<pattern>`;网格、点阵、扫描线用重复 line/circle/rect 显式绘制 |
| `metaphor_loop` | 闭环、反馈系统、运营机制 | `path`, `geometric_shape` | 不只画 4 个圆节点旁边必须补机制表、KPI 标签、输入输出或责任说明 |
| `spotlight_annotation` | 问题定位、架构标注、案例诊断 | `spotlight`, `annotation` | 发光用多层半透明 circle/rect/path 模拟,不依赖复杂 filter标注线和 callout 必须对齐目标 |
| `fake_ui_dashboard` | 产品能力、CLI/平台/监控展示 | `dashboard`, `micro_chart` | 不要把 3 张指标卡伪装成 dashboard必须有 UI frame、状态栏、图表/日志/趋势等操作界面细节 |
| `brand_system` | 系列化 deck、主题页、收尾页 | `typography`, `geometric_shape` | 不只换颜色;必须复用标题位置、边栏、编号、强调色、图标线宽或背景 motif |
`svg_preflight.py` 会校验 `visual_recipe` 枚举、必填字段、recipe required primitives、8 页以上 recipe family 多样性,以及 plan 声明的 primitives 是否能在 SVG source 中检测到。生成器不能只在 plan 里声明 recipe实际仍画 XML 式卡片。
### 生成阶段 Fail-Fast Gate
`slide_plan.json` 不是说明文档,而是生成阶段的硬契约。生成器必须先通过 plan gate再渲染 SVG本地 `svg_preflight.py --plan` 失败时禁止调用 live API。
每页 SVG plan 必填:
| Field | 作用 | 失败后处理 |
|---|---|---|
| `renderer_id` | 标识具体渲染器/几何结构 | 换真实 renderer不用 `two_column_1` 这类假命名 |
| `layout_family` | 做 deck 级版式多样性检查 | 相邻页重复时换阅读方向、主视觉位置或信息结构 |
| `visual_recipe` | 说明这页为什么值得走 SVG | 从 recipe catalog 选择,不能自造枚举 |
| `required_primitives` | 这页必须在 SVG source 中真实出现的 primitive | 至少覆盖 recipe required primitives |
| `svg_primitives` | 实际计划绘制的 primitive | 必须覆盖 `required_primitives` |
| `visual_intent` | SVG 视觉表达目的 | 写清楚 SVG-native 价值,不写空泛风格词 |
| `visual_focal_point` | 页面视觉焦点 | 用于判断布局是否围绕主视觉组织 |
| `xml_like_risk` | 退化成普通 XML 卡片页的风险 | 明确说明不用 SVG 会丢失什么结构 |
| `content_density_contract` | 信息密度硬契约 | 高密度页必须量化,例如 `dashboard >= 4 metrics` |
| `asset_contract` | 图片/素材来源与许可契约 | 无图写 `none_required`Preview 网络图必须记录 `retrieval_query` / `source_url`,授权未确认可写 `license=preview_unverified` 且不阻断;正式交付必须补 source/license/local path 或替换 |
| `risk_flags` | 生成风险显式登记 | 无风险用空数组;不要省略字段 |
| `source_policy` | 缺数据/数字声明处理策略 | 防止自动扩写时编造业务数字 |
deck 级硬门禁:
- 用户未说明页数,或只说“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时默认 `page_count=10`;不要仅因页数缺失而停下来追问。明确“一页 / 单页 / onepage / one slide / 只要封面”才按 `page_count=1`。默认 10 页必须包含 closing slide并满足 10 页 deck 的 layout / renderer 多样性门禁。
- 8 页以上必须有明确 closing slide。
- 10 页以上至少 5 种 `layout_family`
- 不允许连续 3 页使用同一 `layout_family`
- 8 页以上至少 5 种 `visual_recipe` family。
- 10 页以上至少 5 种真实 `renderer_id`
- 高密度页必须有量化 `content_density_contract`,不能只写“信息丰富”。
量化密度契约建议:
```text
matrix/table >= 6 cells
timeline >= 4 nodes
dashboard >= 4 metrics
flow >= 4 stages
risk_grid >= 4 items
comparison >= 4 rows or columns
```
如果 SVG source 无法满足对应数量,`svg_preflight.py` 会报 `plan_content_density_contract_not_met`,生成器必须补真实结构,不要只改字段名。
### 生成前强约束
以下规则来自实际 SVGlide live 生成、回读和修复经验,生成器必须先满足这些规则,再追求视觉复杂度。
- MUST: 默认使用 Lark Slides 当前回读画布 `960 x 540`,即 root 写成 `width="960" height="540" viewBox="0 0 960 540"`。不要默认用 `1280 x 720`,否则服务端回读后可能整页偏大并裁切。
- MUST: 主体元素使用安全区,建议 `safe = x:48 y:40 w:864 h:460`。除全屏背景外,文本、卡片、图表、标签、节点和图例都必须落在安全区内。
- MUST: 多页 deck 应包含明确的 closing slide。8 页以上讲解/汇报型 deck 不要把 roadmap / next-playbook 当作结束页;最后一页应包含 `closing``summary``Q&A``Thanks` 或下一步联系信息。
- MUST: `slides[]` 必须记录 `renderer_id`,且它要对应真实几何结构,而不是 `two-column-1` / `two-column-2` 这种名字变化。10 页以上 deck 至少 5 种 renderer/layout family不得连续 3 页使用同一 renderer。
- MUST: `slides[]` 必须记录 `layout_family``visual_recipe``visual_intent``visual_focal_point``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy``asset_contract` 应尽量记录MVP 阶段缺失只 warning。没有 SVG-native recipe 的页面不应走 `slides +create-svg`,应改用普通 Slides XML 或重新选择 SVG recipe。
- MUST: `visual_recipe` 必须来自 catalog`svg_primitives` 必须覆盖该 recipe 的 required primitives。`renderer_id` 不能替代 `visual_recipe`
- MUST: 8 页以上 SVG deck 至少使用 5 种 visual recipe family不能整套 deck 都是卡片、双栏或普通 dashboard。
- MUST: 高密度页必须声明 `density_structure` 和量化 `content_density_contract`,例如 `matrix/table >= 6 cells``timeline >= 4 nodes``dashboard >= 4 metrics``flow >= 4 stages``risk_grid >= 4 items`。只有“大标题 + 大图 + 2-3 个短 chip”不算高密度。
- MUST: 来源不足、附件缺失、用户未提供数据时,必须在 plan 中写 `source_status``source_policy`,并在页面上显式表达“待从附件补齐 / 来源缺失 / no numeric claims”。不要编造客户、排名、真实论文数据、金额、占比、链接、logo 或引用。
- MUST: `foreignObject` 文本样式使用显式 CSS`font-size``font-weight``font-family``color``line-height``text-align`。不要用 `font:` shorthand 表达关键字号和加粗。
- MUST: 白色或接近白色的文字必须完整落在深色 shape 承载底上。标题、封面副标题、CTA、页脚等不能跨出深色底压到浅色图片、白色蒙层或白底上需要时扩大色块、加深色背板/遮罩,或改用深色文字。
- MUST: 圆形/椭圆节点只承载短标签,不承载解释句。节点内 `foreignObject` bbox 必须小于节点 bbox微解释、指标、下一步和注释放到独立说明卡、图例、机制表或外侧 callout。
- MUST: 提交前和 live 回读后都检查边界和重叠:非背景元素不得越过 `960 x 540`,第 2/3 页等信息密集页必须额外检查 text bbox overlap。
- SHOULD: 如果本地预览使用更大画布,例如 `1280 x 720`,必须在输出给 `slides +create-svg` 前按比例换算为 `960 x 540`,而不是只改 root viewBox。
### 生成器实现约束与 Preflight
生成器必须先把高概率错误拦在本地,再调用 `lark-cli`。不要依赖 live 创建后的人工修补来发现基础问题。
实现约束:
- MUST: SVG 生成 helper 的返回类型保持一致。推荐统一返回 `string`,或统一返回 `string[]` 后在页面末尾 `flat().filter(Boolean).join("\n")`;不要混用 `...items.map(...).join("\n")`,这会把已拼好的 SVG 标签按字符展开,生成非法 XML。
- MUST: 所有组件都从稳定布局盒推导坐标,避免散点手调。文本、标签、图例、曲线端点和卡片内容应有明确的父盒和对齐规则。
- MUST: 生成脚本要先写 deck plan / asset list再写页面不能边补坐标边生成最终 SVG。
- MUST: 生成器要把 preflight 规则前移为本地 assert。写 SVG 前先由实际组件 manifest 反推出 `svg_primitives`,再检查 `visual_recipe` required primitives、`required_primitives``content_density_contract` 数量、主体 safe area、文本 bbox 和最小文本框高度;断言失败时修组件或布局,不要只改 `slide_plan.json` 字段。
- MUST: 高密度结构要由组件实际数量驱动,例如 `scorecard >= 4 metrics` 必须生成 4 个能被识别为 metric/bar/card 的元素;`timeline >= 4 nodes` 必须生成 4 个真实节点和标签;不要用文字描述冒充结构。
- MUST: 文本组件要按字号、行高和预估行数计算最小 `foreignObject` 高度。卡片、节点、脚注、图例的正文框不得出现 0、高度个位数或明显低于一行文字的 bbox。
- MUST: 主体文本、卡片、图表、标签、节点和图例必须落在 safe area全画布背景、边缘承载底、图片遮罩和装饰边框可以超出 safe area但应只承担背景/承载作用,不承载关键文本。
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
- SHOULD: 把每页的 `safe``titleBox``visualBox``textBox` 等布局盒保存为可检查数据,便于自动计算越界和重叠。
推荐生成顺序:
```text
deck/page plan
-> layout boxes
-> components with emitted primitive manifest
-> generator asserts: recipe/primitives/density/text/safe-area
-> write SVG + slide_plan.json from the same manifest
-> svg_preflight.py --plan ...
-> dry-run / live create / readback
```
### 本地 HTML 预览(建议)
HTML 预览是生成阶段的轻量质检,不是 SVGlide 协议或 CLI API 的硬依赖。
- SHOULD: 生成 SVGlide deck 后、调用 `slides +create-svg` 前,生成一个本地 `preview.html`,把每页 SVG 按 16:9 画布嵌入,并展示页码、标题、`renderer_id` / `visual_recipe`、图片资产状态、preview-only 图片来源和明显 warning。
- SHOULD: 如果当前 agent、IDE 或浏览器工具支持打开本地文件,打开 `preview.html` 进行人工或截图式预览,优先检查:
- 页面是否空白、明显裁切或整体偏大。
- 标题、正文、图片和装饰元素是否重叠。
- 白色/浅色文字是否压到浅色背景或图片亮部。
- 相邻页面是否版式过度重复。
- 信息密度是否明显不足,尤其是高密度页是否真的有 matrix/table/timeline/dashboard/flow/risk grid。
- 结尾页是否存在。
- 图片是否显示,是否有破图、空图片框、图片过少或 preview-only 来源未记录。
- SHOULD: 在最终产物目录记录 `preview.html` 路径;如果未生成或无法打开,说明原因,并继续执行 preflight / dry-run / readback。
- MUST NOT: 用 HTML 预览替代 `svg_preflight.py``slides +create-svg --dry-run` 或 live readback。HTML 预览主要提前发现审美、布局和素材问题服务端转换后的字体、path bbox、图片 token 和部分 SVG 效果仍必须通过 readback 验证。
打开预览后必须按 [svg-aesthetic-review.md](svg-aesthetic-review.md) 做一次人工或截图式审查。重点看所有页面的标题区、装饰线、badge、文本框、图片框、safe area、重复版式和 SVG 视觉优势;如果多页出现同类问题,修生成规则后重新生成,不要只逐页微调坐标。
本地 preflight 必须在 `slides +create-svg` 前执行,失败即停:
- `python3 skills/lark-slides/scripts/svg_preflight.py --plan .lark-slides/plan/<deck-id>/slide_plan.json --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
- root 是 `width="960" height="540" viewBox="0 0 960 540"`
- root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。
- plan 中每页 `layout_family``visual_recipe``visual_intent``visual_focal_point``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy` 完整,且 recipe required primitives 能在对应 SVG source 中命中。`asset_contract` 在 MVP 阶段缺失只 warning有条件时仍应补全。
- 禁止 SVG 退化成 XML-like 卡片页:如果页面基本只有 `rect + foreignObject`,且没有 path、gradient、image overlay、annotation、micro chart、icon、texture、spotlight、flow 等 SVG-native primitivepreflight 必须失败。
- 禁止零尺寸元素;文本框、图片、卡片和圆/椭圆必须有正向宽高,不能生成 `height="0"` 的隐藏说明。
- `<image opacity="...">` 或图片 style 里写 `opacity:` 在 MVP 阶段只 warning当前转换链路不会稳定保留到 readback `<img>`。需要淡化图片时,优先把透明度预合成进 PNG/JPG或在图片上方加半透明 `rect` 遮罩。
- 禁止白色/浅色文字跨出深色承载底;如果 preflight 报 `light_text_without_dark_backing`,优先扩大深色背景或加文本背板,不要只缩小字号。
- 禁止把解释文字塞进圆形/椭圆节点;如果 preflight 报 `node_text_overflow`,节点内只保留短标签,把说明迁移到旁边卡片、表格或图例。
- 警惕 `circle` / `ellipse``stroke-width`;当前转换链路可能只保留 border color 而丢失 width。关键圆环、节点外圈和粗描边用双层填充圆/椭圆模拟,或改成 path/rect。
- 禁止关键路线、闭环、流程连接、timeline rail 使用 `stroke-dasharray`;普通装饰虚线也会 warning。关键路线必须用显式短线段或小圆点 markers 组成,不要把虚线作为唯一视觉表达。
- 禁止 `font:` shorthand 和空图片框。MVP 阶段 http(s) / data URL 图片、未下载远程图片只 warning正式交付和可见性要求高的 deck 仍应下载到本地并走 `@./path` 上传或使用 file token。
- 禁止 unsupported path command`path d` 只含 `M/L/H/V/C/Q/Z`
- 非背景元素不得越界;主体元素应在 safe area 内。
- 文本框做 bbox overlap 近似检查,尤其是目录、痛点、竞品表、案例图表和总结页。
- 图片资产文件存在、大小合理,或 http(s)/data URL 能在 preview 中显示。Preview 阶段来源/授权不完整只 warning但必须用 `asset_contract.license=preview_unverified``risk_flags=["image_preview_only"]` 显式标记;正式交付再补齐来源/授权或替换。
- deck plan 通过 renderer 多样性、layout family 多样性、closing slide、高密度结构、资产契约、来源保护六类校验。
创建顺序:
```text
generate deck plan -> generate assets -> generate SVG files
-> optional preview.html and browser preview when supported
-> local preflight with --plan -> lark-cli slides +create-svg --dry-run
-> live create -> xml_presentations get readback
-> readback bbox / text overlap / closing slide checks
```
readback 不能省略。服务端会把 SVGlide 转成 Slides XML文字 bbox、path bounds 和图片 token 可能和本地 SVG 预估不同;本地 preflight 负责拦住确定错误readback 负责发现转换后的版式漂移。
### Deck 级密度规划
生成多页 SVG deck 前,先写 deck-level plan。页面类型只定义叙事职责密度由 `deck_type`、受众、页面目的和节奏共同决定,不要把某个 page type 永久绑定为固定密度。
最小 plan schema
```json
{
"deck_type": "explain | decision | product | brand | technical | education | report",
"audience": "who will read it",
"goal": "what the deck should make the audience understand or decide",
"density_strategy": "how low/medium/high density pages are distributed",
"asset_strategy": "which query/topic-related web images should be searched and fetched, where they will be used, preview source/url/license risk, and production replacement plan if needed",
"visual_rhythm": "how layout, imagery, charts, and text density vary across pages",
"slides": [
{
"page": 1,
"page_type": "cover",
"density": "low",
"density_mode": "visual-dense",
"takeaway": "one sentence the audience should remember",
"evidence": [],
"visual_structure": "full-bleed image with title overlay",
"layout_guardrails": ["large hero title", "no dense body copy"]
}
]
}
```
常用 `page_type`
```text
cover, opener, agenda, section-divider, context, problem, opportunity,
executive-summary, content, data, comparison, process, case-study, demo,
architecture, system, roadmap, timeline, decision, recommendation,
risk, tradeoff, summary, closing, q-and-a, appendix
```
密度规则:
- MUST: 每页都要有明确 `takeaway`,即使是封面、分隔页和结束页。
- MUST: 每个 SVG deck 默认都要包含真实图片资产,不要全程只用矢量 shape 冒充“配图”。Preview 阶段应优先根据用户 query、deck 标题和页面主题去网络检索并拉取强相关图片,再补充产品截图、网页截图、场景图、材质纹理、图鉴图和 AI 生成图增强视觉冲击;展示型、宣传型、产品型、品牌型和案例型 deck 至少包含 3 处图片使用,其中至少 1 页使用全幅或半出血图片主视觉。
- MUST: 高密度页必须有承载信息的视觉结构,例如矩阵、流程、地图、时间线、标注图、案例卡或手绘微图表,不能只有装饰图形。
- MUST: 生成器必须先扩写页面“结构信息”,再绘制 SVG。信息密度不足时优先补结构化解释层例如编号标签、微解释、比较维度、轴线、图例、阶段、来源状态、下一步而不是把同一句话换写成多个 chip。
- MUST: 流程页、闭环页、机制页和产品体系页不能只有“4 个圆节点 + 短标签”。至少补 1 层结构化信息例如机制表、KPI 标签、触发条件、责任/频率、输入输出、风险提示或下一步动作。
- SHOULD: 高密度内容页通常包含 3-6 个信息块和若干可读细节,但 executive brief、品牌页、产品视觉页、短汇报可以降低数量只保留强结论、关键证据和视觉锚点。
- SHOULD NOT: 不要让所有高密度页长成同一种“主结论 + 3-6 卡片 + 3 个 callout”模板。
- MUST NOT: 缺少素材或数据时不要编造数字、客户名、logo、排名、引用或真实案例用 qualitative label、relative scale、hypothesis/assumption 标注兜底。
### 结构示例
8-10 页讲解型 deck 可参考这个节奏,但不要把它当成唯一模板;如果 deck 已经包含 roadmap / playbook仍建议再补一页 closing
```text
cover -> opener/context -> agenda/map -> content -> data/comparison
-> process/system breakdown -> case-study/demo -> content/implications
-> summary -> closing
```
5 页决策汇报优先前置结论:
```text
cover -> executive-summary -> options/comparison -> recommendation/risk -> next steps
```
6 页产品/品牌 deck 可以强化视觉叙事:
```text
cover -> value proposition -> user scenario -> feature map/demo
-> proof/roadmap -> closing
```
边界处理:
- 3-5 页短 deck 可以省略 agenda把 summary 并入 closing。
- 15 页以上长 deck 应增加 section-divider 或 recap避免连续高密度阅读疲劳。
- 技术方案要混合 architecture、process、tradeoff、risk不要连续堆文字。
- 教学讲解要前置 context / concept map逐步增加密度。
- 素材不足时用抽象视觉系统、定性矩阵、annotated wireframe、scenario card 兜底,并标明假设。
### 先定义布局盒
不要直接手写散点坐标。每页先定义稳定布局盒,再把文字、图形、图例和图片放进盒内:
```text
page = 960 x 540
safe = x:48 y:40 w:864 h:460
titleBox = x:54 y:52 w:600 h:96
visualBox = x:516 y:176 w:350 h:260
notesGrid = x:54 y:430 w:760 h:48
```
生成后检查:
- 关键元素必须在 safe area 内。
- 同组元素使用同一个父盒推导坐标。
- 图例、标签、指标不能浮在不上不下的位置,必须相对主视觉左/右/下边对齐。
- 如果页面有圆、节点、卡片或框体,内容中心应和外框中心基本一致,不靠手调 `x + 10``y + 10` 维持观感。
- 不要把 1280x720 的坐标直接提交给 `slides +create-svg`。当前服务端回读画布通常是 960x540错误坐标系会表现为每页偏大、右侧卡片裁切、底部标签越界。
### 文本安全余量
`foreignObject` 文本优先使用显式 CSS。为了服务端转换后保留样式字号、加粗、颜色、行距和对齐必须写成独立属性不要把关键样式藏在 `font:` shorthand 或只写在复杂外层 wrapper 上:
```xml
<foreignObject slide:role="shape" slide:shape-type="text" x="54" y="62" width="600" height="42">
<div xmlns="http://www.w3.org/1999/xhtml"
style="margin:0;padding:0;font-size:30px;font-weight:900;font-family:Arial,'Source Han Sans SC';color:#111827;line-height:1.12;text-align:left;letter-spacing:0;">
关键结论:增长来自三件事
</div>
</foreignObject>
```
中文和混排字体要留安全高度:
- subtitle 不小于 64px。
- note / chip 单行文本盒不小于 20px。
- 小型标签文本盒不小于 14px。
- 多行文字要按行高预估高度,再额外留 8-12px。
- 右侧图例或矩阵格里的文字不得贴边,水平 padding 至少 10-14px。
- 白色/浅色文字的 bbox 必须完全落在深色 rect/card/overlay 内;封面标题如果跨出色块,应优先扩大色块或改成深色字,不要让白字压在浅色图片或白色蒙层上。
- 圆形/椭圆节点内只放短标签,解释文字移动到节点外的 callout、legend 或机制表;不要让圆内文本框宽度超过圆形直径。
- 服务端支持 `foreignObject` 内的 `<br />`。为了本地预览和标题排版稳定,标题/大段文本优先使用多个块级 `div``p` 控制行高,不要只靠 `<br />` 调整复杂布局。
- 如果需要垂直居中,优先通过更准确的文本框高度、段落行高和 y 坐标解决;布局 wrapper 可以使用,但实际文字节点仍要带显式 `font-size` / `font-weight` / `color`
### 几何与 path 安全线
leaf 几何属性必须写数字或 `px`,不要生成百分比、`em/rem``calc(...)`
```xml
<rect slide:role="shape" x="80" y="96" width="420px" height="240px" />
```
`path d` 只生成 `M/L/H/V/C/Q/Z` 命令。不要生成 `A``S``T` 等命令;需要圆角或弧线时,用 `C` / `Q` 近似,或改用 `circle` / `ellipse` / `rect`
Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`,先在布局盒里换算成绝对坐标。
### 版式节奏
同一 deck 不能连续复用同一种“暗色网格 + 左文案 + 右卡片 + 底部 chips”。10 页左右的讲解型 deck 至少混用这些结构:
- 封面 / 全幅图片背景页。
- 目录矩阵页或行业地图页。
- 左文右图 / 左图右文双栏页。
- 全幅路线图或时间线页。
- 2x2 / 2x4 总结矩阵页。
- 数据仪表页、流程页、对比页或案例页。
相邻页面至少改变一个主结构维度:主视觉位置、网格列数、图片用法、文本密度或阅读方向。
### 图片使用与 Preview Image Mode
默认必须规划和使用图片资产。Preview 阶段的目标是验证 SVGlide 的视觉表达上限,版权/授权不作为阻断条件;不要因为 license 未确认就退回纯矢量或低信息卡片页。推荐先从用户 query、deck 标题、章节标题和页面 takeaway 生成 2-5 个图片检索词,去网络检索并拉取主题强相关图片;再补充网页截图、产品截图、图库图、新闻/历史/艺术/科普图片、材质纹理或 AI 生成图做占位视觉。必须在 plan / README 里记录 `retrieval_query`、来源 URL或标记 `license=preview_unverified`,并避免明显不适当素材、敏感肖像和会造成商业背书误导的 logo/商标。正式交付时,再统一替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。
最稳流程仍然是先下载或生成到本地,再写成本地占位符:
```xml
<image slide:role="image" href="@./assets/hero.jpg" x="0" y="0" width="960" height="540" />
```
推荐的网络拉图流程:
1. 从用户 query、deck title、page takeaway、章节标题中提取 `retrieval_query`,优先使用具体名词、场景、人物、作品、产品、地点、历史事件或学科对象,避免只搜抽象词。
2. 对封面、章节过渡页、案例页、教学解释页和产品/品牌页优先执行网络图片搜索或网页截图获取,选择和主题直接相关的真实图片,不用无关风景图凑数。
3. 能下载时先保存到 `assets/` 并用 `@./assets/...` 引用;来不及下载时可以先保留 http(s) URL 进入 preview但 live/readback 后必须确认可见。
4. 每张图在 `asset_contract` 记录 `retrieval_query``source_type``source_url``retrieved_at``license=preview_unverified``usage_page``replacement_required=true`
5. 网络不可用或无法找到强相关图片时,才退回 AI 生成图、程序化纹理或纯 SVG 视觉,并在 `risk_flags``network_image_fetch_unavailable`
图片不只用于局部卡片背景,也可以作为整页背景、半出血主视觉、材质纹理、案例示例、产品截图、数据仪表截图、网页/应用界面截图、人物/场景图、图鉴封面、历史/艺术/科学素材或产品细节局部。作为整页背景时,必须叠加半透明遮罩或暗角,保证标题和正文对比度。
图片数量与用法建议:
- MUST: 在 `asset_strategy` 或产物 README 中记录图片检索词、图片来源、授权/许可类型、下载 URL 或生成方式Preview 阶段无法确认授权时写 `license=preview_unverified``replacement_required=true`preflight 不阻断,最终交付应替换为可授权资产。
- MUST: 5 页以上 deck 至少使用 2 张真实图片8 页以上 deck 至少使用 4 张;宣传/产品/品牌/案例/教学型 deck 至少使用 5 张或至少 40% 页面含图片。
- MUST: 封面优先使用图片或图片+抽象图形混合主视觉,不要只用网格、光效和几何背景。
- MUST: 案例页优先使用行业场景图、产品截图、仪表盘截图或真实质感背景,并叠加数据 callout。
- MUST: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景、标注型截图、图鉴式小图和局部裁切特写,避免所有图片都只是小卡片背景。
- SHOULD: 对教育、历史、艺术、医学、产品讲解等主题,优先用图片建立具象认知:人物、器物、场景、局部特写、对比图、流程截图、资料封面或时间背景图。
- MUST NOT: 保留空图片框或破图。Preview/MVP 阶段允许 http(s) 外链或 data URL 先进入 preflight warning但 live/readback 后必须确认可见;正式交付应替换为本地 `@./path` 或 file token。
Preview 阶段优先使用这些来源来快速获得丰富视觉;正式交付时再逐图确认授权、署名和替换计划:
| Source | 适合用途 | Preview 规则 |
|--------|----------|------|
| Web image search / topic query | 和用户 query、页面主题、作品/人物/地点/产品直接相关的真实图片 | 优先使用;记录 `retrieval_query`、图片页 URL 和 `preview_unverified`,正式交付再确认或替换 |
| Unsplash / Pexels / Pixabay | 高质量摄影、封面背景、场景图 | 结合主题 query 检索;记录图片页 URLlicense 可先写 `preview_unverified`,正式交付再确认 |
| Openverse / Wikimedia Commons | 百科、历史、技术、公共领域素材 | 记录单图 URL 和作者/页面preview 可先用,正式交付补 license / attribution |
| The Met / Smithsonian / NASA Open Access | 艺术、科学、历史、航天视觉 | 记录条目 URLpreview 可先用,正式交付确认 Open Access / 第三方权利 |
| 官网 / 产品页 / 新闻图 / 搜索图 | 产品截图、竞品页、事件背景、真实语境 | Preview 可作为视觉占位;必须标记 `license=preview_unverified`,正式交付替换或删去 |
| AI 生成图 / 程序化纹理 | 抽象背景、材质、概念图 | 记录生成方式和提示词摘要;正式交付确认模型/平台授权 |
素材清单建议字段:
```json
{
"local_path": "./assets/hero.jpg",
"source": "Unsplash",
"retrieval_query": "Beethoven Symphony No. 5 concert hall orchestra",
"source_url": "https://...",
"retrieved_at": "2026-06-08",
"license": "preview_unverified",
"commercial_use": "unknown_in_preview",
"replacement_required": true,
"attribution_required": false,
"usage_page": 1,
"notes": "Preview-only visual placeholder; replace or verify license before production delivery"
}
```
### 信息密度与图鉴感
短 note 不要占一个很宽胶囊。优先写成“编号/标签 + 主句 + 微解释/数值”:
```text
03 GRID ENERGY 86% | storage demand peaks before grid balancing
```
内容页可以用三种方式提高密度,不要把高密度等同于堆文字:
- `text-dense`: 多解释、多证据、多注释,适合背景分析和概念讲解。
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;不要默认依赖 Slides 原生 chart也不要把外部图表截图当成唯一方案。
- `visual-dense`: 高级视觉图案或图片上叠加标注层、数据 callout、局部标签、对比线和图例。
视觉区要补足可读细节,避免只有装饰符号:
- 局部标注、刻度、坐标轴、图例。
- 行业标签、材料纹理、指标卡。
- 路线节点、连接线、层级分区。
- 流程/闭环图旁边补机制表或说明卡,例如“触发条件 / 运营动作 / 衡量指标”,不要把说明句塞进圆形节点内部。
- 小型表格、雷达/柱状/散点等微图表。
### 转换稳定性经验
这些规则来自 live 创建后对比 source SVG 与 readback XML 的结果,属于生成侧必须规避的转换差异:
- `image opacity` 不稳定:本地 SVG 里的 `<image opacity="0.18">` / `<image opacity="0.22">` 可能会在 readback `<img>` 中丢失透明度。MVP preflight 只 warning生成器仍应把淡化效果烘焙进图片本身或使用半透明 shape 遮罩。
- shape opacity 稳定:`rect``circle``path` 等 shape 的 `opacity` 会转换为 XML `alpha`,可用于蒙层、暗角和装饰层。
- circle / ellipse stroke width 不稳定:圆形/椭圆描边可能只保留颜色、不保留宽度。关键外圈使用“外层有色圆 + 内层背景圆”的双 shape ring或用 path 绘制;不要用单个 stroked circle 承载关键视觉。
- dashed stroke 不稳定:`stroke-dasharray` 可能降级,尤其是自定义 path 的虚线闭环。关键路线用短 line segment 或 filled dot markers 手工排布;普通装饰虚线也要经 readback 复核。
- path 会转换为 `type="custom"` 并做 bbox 内坐标归一化,这是预期行为;只要 readback bbox 和视觉位置正确,不算差异。
- 字体会被转换为服务端支持字体,例如 `Noto Sans` / `思源黑体`,因此生成阶段要给 `foreignObject` 留足高度,不要按浏览器本地字体做极限排版。
### 生成后检查
生成脚本或人工复核必须检查:
- 是否已执行本地 preflight且所有 SVG 通过 XML、协议、资产、bbox 和文本重叠检查。
- 是否已执行 `slides +create-svg --dry-run`,确认请求链路是创建 presentation + 按页追加 SVG。
- live 创建后是否已用 `xml_presentations get` 读回,重新检查画布、页数、越界、文本重叠和 closing slide。
- root / leaf role 是否完整。
- 每个 leaf 是否有 [svg-protocol.md](svg-protocol.md) 中列出的几何必填属性。
- 几何属性和 transform 参数是否只使用数字或 `px`
- `path d` 是否只包含 `M/L/H/V/C/Q/Z`
- 文本是否截断、重叠或贴边。
- 内容是否在 safe area 内,关键图例和外框是否对齐。
- 相邻页面是否明显换版式。
- 每页是否有明确 takeaway高密度页的视觉结构是否承载信息而不只是装饰。
- 内容页是否避免了“大标题 + 大图 + 2-3 个短 chip”的低信息布局。
- 自称数据、排名、客户、引用、logo 或案例时,是否有来源;没有来源时是否改为定性或假设表达。
- 图片是否足够丰富并可见;如果 Preview/MVP 阶段暂时保留 http(s) / data URL 或 `preview_unverified` 来源,要记录 warning、确认 live/readback 可见,并在正式交付前列出替换项。
验证记录建议写回 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json``readback_verification` 字段,并在最终回复中简述:
```text
验证记录:
- PreflightN/N SVG 通过 root/role/geometry/path/image/bbox 检查。
- Dry-run已确认 create presentation + N 次 /slide。
- Readback实际页数 N / 预期 N未发现空白页、破图或缺失 closing slide。
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
- 资产Preview 阶段优先丰富图片和 readback 可见性;若保留 http(s)/data URL 或 `preview_unverified` 来源,必须记录 warning。正式交付再替换为本地 @path 自动上传或 file token并补齐授权。
```
## 错误处理
任一页失败时,错误会包含:
- `xml_presentation_id`
- 失败页序号
- 已成功页数
- 已创建的 `slide_ids`
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` markerCLI 会提取并在错误中展示 `svglide_error`,用于定位 `type``page_index``tag_name``element_id``role``hint`
失败后不要假设没有创建任何资源。先把恢复状态写回 plan 的 `recovery` 字段:
```json
{
"xml_presentation_id": "slides...",
"failed_page": 3,
"failed_svg_file": ".lark-slides/plan/<deck-id>/pages/page-003.svg",
"successful_slide_ids": ["abc", "def"],
"svglide_error": {"type": "svg_validation_error", "tag_name": "foreignObject"},
"next_action": "fix source SVG and rerun preflight before retry"
}
```
恢复顺序:
1. 本地 preflight 已失败:修对应 SVG 文件,不要调用 live API。
2. live 添加页失败且带 `svglide_error`:按 `type` / `tag_name` / `hint` 收敛 SVG 子集,例如降级复杂 filter、path、CSS 或文本结构。
3. plain XML 在同一路由成功但 SVG 失败:优先确认目标 server lane 是否部署了 SVGlide parser不要盲目重写整套 deck。
4. SVG 通过本地 preflight 且失败在第 1 页,服务端只返回 generic `nodeServer invalid param`:优先检查 `lark-cli` 环境、代理和 PPE/BOE lane 是否命中目标 slide server。不要先把已通过协议校验的 deck 改回低质量 SVG。
5. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。
### 编辑已创建的 SVG deck
SVG deck 后续编辑走双轨,不承诺 source SVG id 能稳定映射到 readback XML block id
| 修改类型 | 推荐路径 | 说明 |
|----------|----------|------|
| 小改标题、文本、图片或坐标 | `xml_presentation.slide.get` 读回 XML -> 找当前 block_id -> `slides +replace-slide` | 使用转换后的 XML 做块级编辑,页序和 slide_id 不变 |
| 大幅换版式、重画图表、调整视觉系统 | 修改 source SVG -> 重新 preflight -> 重新创建或替换目标页 | 保持 SVG 的视觉表达优势,避免在转换后 XML 上手搓复杂 SVG 结构 |
| 无法定位 block_id 或映射不可信 | 回 source SVG 修改 | 不生成 `edit-map.json`,除非服务端或转换结果能证明 source id 可稳定保留 |
小改前必须重新 `slide.get` 拿最新 block id 和 revision大改后必须更新同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,保持 plan、SVG 文件、创建结果和验证记录一致。

View File

@@ -6,7 +6,7 @@
## Required Flow
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
1. 理解用户需求,必要时澄清主题、受众、页数、风格。SVGlide 新建 deck 如果用户未说明页数,或只说“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”等模糊表达默认按 10 页写入 `page_count` / `target_slide_count`,不要仅因页数缺失而停下来追问;只有明确“一页 / 单页 / onepage / one slide / 只要封面”才按 1 页。
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
@@ -67,6 +67,18 @@ Exception:
"accent": "Used only for key numbers, conclusions, or focus markers."
}
},
"style_preset": "raw_grid",
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
"style_system": {
"palette": {
"background": "#F5F5F5",
"text": "#0A0A0A",
"accent": "#F2D4CF"
},
"typography": "strong title, readable native text labels",
"background_strategy": "muted grid panels with one stable background family",
"motif": "dense grid panels with restrained accent labels"
},
"typography_constraints": {
"title_max_lines": 2,
"body_max_lines_per_box": 2,
@@ -107,6 +119,9 @@ Top-level fields:
- `audience`: target readers or listeners and their assumed background.
- `theme_style`: visual tone, palette direction, and professional style.
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
- `style_preset`: required for SVGlide SVG decks. Choose one id from `references/style-presets.json`; omit only for non-SVG XML/SXSD plans.
- `style_selection_reason`: required for SVGlide SVG decks. Explain why the preset fits the audience, topic, density, and expected tone.
- `style_system`: required for SVGlide SVG decks. Translate the selected preset into concrete palette, typography, background strategy, and motif rules. This is separate from `visual_system`: `visual_system` describes the deck identity, while `style_system` records the executable style preset translation.
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
- `slides`: ordered page plans.
@@ -122,6 +137,14 @@ Each slide must include:
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.
SVGlide SVG slides must also include:
- `visual_recipe`: the SVG-native page recipe, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`.
- `visual_signature`: the page's distinctive SVG visual memory point compared with a normal XML/PPT template.
- `svg_effects`: canonical effect names actually used or planned, such as `path`, `connector_flow`, `gradient`, `texture`, `chart_geometry`, or `image_overlay`.
- `required_primitives` and `svg_primitives`: the planned SVGlide-safe primitives that must be present in the SVG source.
- `xml_like_risk`, `content_density_contract`, `risk_flags`, and `source_policy`: quality and source-safety fields consumed by `svg_preflight.py --plan`.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:

View File

@@ -0,0 +1,542 @@
{
"version": "2026-06-10",
"source": "beautiful-feishu-whiteboard",
"canvas": "960x540",
"selection_rule": [
"Choose intensity first: Restrained for quiet/formal decks, Balanced for most business or training decks, Bold for poster-like or high-energy decks.",
"Use preset style tokens to shape palette, panel treatment, connector density, typography scale, and texture.",
"Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content."
],
"groups": {
"Restrained": {"expected_count": 9, "use_when": "Serious, quiet, editorial, institutional, or text-first decks."},
"Balanced": {"expected_count": 15, "use_when": "General business, technical, educational, and explanatory decks."},
"Bold": {"expected_count": 11, "use_when": "Posters, showcases, events, playful explainers, and high-energy visual impact."}
},
"presets": [
{
"style_id": "avocado_press",
"display_name": "Avocado Press",
"group": "Restrained",
"source_token": "TIBNwZ6fLhfPh1bZlAQuFRnFswW",
"formality": "high",
"vibe": ["editorial", "fresh", "structured"],
"best_for": ["agenda.structured", "process.flow", "quote.insight"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#0055A4", "accent": "#DCF4A2", "support": ["#0055A4"]},
"shape_language": {"panel_treatment": "editorial blocks with bright accent labels", "corner_radius": "low", "border_weight": "medium", "texture": "clean print-like structure"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep text native and use explicit connector lines for process structure."},
"quality_oracle": {"expected_style_signals": ["blue structural labels", "avocado accent", "editorial spacing"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
},
{
"style_id": "grove",
"display_name": "Grove",
"group": "Restrained",
"source_token": "IOCVwTYCYhhUj9bbkAwuncDTslf",
"formality": "high",
"vibe": ["institutional", "organic", "calm"],
"best_for": ["architecture.layered", "section.divider", "process.flow"],
"avoid_for": ["brand_system"],
"palette": {"background": "#E8E4D6", "text": "#192B1B", "muted": "#D4CFBF", "accent": "#C8524A", "support": ["#DEDAD0"]},
"shape_language": {"panel_treatment": "soft editorial panels with deep green hierarchy", "corner_radius": "low", "border_weight": "light", "texture": "warm paper bands"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 62, "source_text": 44, "source_shapes": 13, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["architecture", "section", "process"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use grouped green panels and sparse connectors rather than dense arrows."},
"quality_oracle": {"expected_style_signals": ["deep green title mass", "muted paper background", "small warm accent"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 2}}
},
{
"style_id": "jade_lens",
"display_name": "Jade Lens",
"group": "Restrained",
"source_token": "T0eswEvY1h6uSZbbt1FujZp0sZf",
"formality": "high",
"vibe": ["research", "clean", "knowledge"],
"best_for": ["timeline.roadmap", "architecture.layered", "agenda.structured"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#F5F1EE", "text": "#0E5A3C", "muted": "#2BA483", "accent": "#2CAE8C", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "jade framed cards with layered labels", "corner_radius": "medium", "border_weight": "medium", "texture": "lens-like panels"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 72, "source_text": 44, "source_shapes": 19, "source_connectors": 9}},
"slide_translation": {"recommended_layouts": ["timeline", "architecture", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep jade labels as native text; simplify nested frames when crowded."},
"quality_oracle": {"expected_style_signals": ["jade green framing", "white content panels", "medium connector scaffolding"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "long_table",
"display_name": "Long Table",
"group": "Restrained",
"source_token": "VrJhwVUTwhjU2zbBpI7uEjy2szg",
"formality": "high",
"vibe": ["procedural", "responsibility", "planning"],
"best_for": ["table.visual-summary", "process.flow", "timeline.roadmap"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#FAF1E2", "text": "#1F2329", "muted": "#F2E5CF", "accent": "#B53D2A", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "long horizontal table bands with clear separators", "corner_radius": "low", "border_weight": "medium", "texture": "tabular rows"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 9, "source_connectors": 17}},
"slide_translation": {"recommended_layouts": ["table", "process", "timeline"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Prefer table/grid native shapes; connector count can be reduced if slide becomes cramped."},
"quality_oracle": {"expected_style_signals": ["long horizontal rows", "red emphasis", "explicit flow connectors"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "macchiato",
"display_name": "Macchiato",
"group": "Restrained",
"source_token": "Jhl9w3gZghgXzeb6WLwu44VXsMg",
"formality": "high",
"vibe": ["quiet", "editorial", "warm"],
"best_for": ["quote.insight", "section.divider", "comparison.two-column"],
"avoid_for": ["dashboard.kpi-grid"],
"palette": {"background": "#EDE7DD", "text": "#25211B", "muted": "#9A917F", "accent": "#6E6558", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "minimal editorial blocks with low contrast", "corner_radius": "low", "border_weight": "light", "texture": "coffee paper tone"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 5, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["quote", "section", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use whitespace and typographic hierarchy instead of many decorative nodes."},
"quality_oracle": {"expected_style_signals": ["low contrast warm neutrals", "large quiet text", "few panels"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 1}}
},
{
"style_id": "monochrome",
"display_name": "Monochrome",
"group": "Restrained",
"source_token": "ApDnwnul9hlwg8b4Jl1uDweAs2c",
"formality": "high",
"vibe": ["serious", "technical", "minimal"],
"best_for": ["architecture.layered", "quote.insight", "agenda.structured"],
"avoid_for": ["brand_system"],
"palette": {"background": "#FAFADF", "text": "#1A1A16", "muted": "#8A8A80", "accent": "#5E5E54", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "monochrome rules and restrained blocks", "corner_radius": "low", "border_weight": "light", "texture": "minimal editorial grid"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 3, "source_connectors": 8}},
"slide_translation": {"recommended_layouts": ["architecture", "quote", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Rely on black/gray hierarchy and simple connectors; avoid fake color accents."},
"quality_oracle": {"expected_style_signals": ["black text hierarchy", "single neutral accent", "minimal blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 1}}
},
{
"style_id": "papier_bleu",
"display_name": "Papier Bleu",
"group": "Restrained",
"source_token": "HWi5woaS8h1D4EbKutnulYWdsWc",
"formality": "medium",
"vibe": ["blueprint", "clear", "knowledge"],
"best_for": ["process.flow", "timeline.roadmap", "image.story"],
"avoid_for": ["brand_system"],
"palette": {"background": "#FAF3EB", "text": "#1A3C8F", "muted": "#4FB8D8", "accent": "#72D0E9", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "blue paper panels with airy blocks", "corner_radius": "medium", "border_weight": "medium", "texture": "light blueprint geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 64, "source_text": 38, "source_shapes": 22, "source_connectors": 4}},
"slide_translation": {"recommended_layouts": ["process", "timeline", "image"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use blue diagram cards and short labels; avoid overusing cyan fills."},
"quality_oracle": {"expected_style_signals": ["blue structural panels", "cream paper base", "clear diagram labels"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
},
{
"style_id": "reading_room",
"display_name": "Reading Room",
"group": "Restrained",
"source_token": "Wx8Ow5ThFhDn5Lb1VFzu5bDEskD",
"formality": "high",
"vibe": ["paper", "training", "reading"],
"best_for": ["quote.insight", "section.divider", "paper.explainer"],
"avoid_for": ["fake_ui_dashboard"],
"palette": {"background": "#F6EBD8", "text": "#0B0A09", "muted": "#F1DAB1", "accent": "#DE916A", "support": ["#D6C7CC"]},
"shape_language": {"panel_treatment": "reading-card panels with warm paper blocks", "corner_radius": "low", "border_weight": "light", "texture": "bookish paper"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 54, "source_text": 38, "source_shapes": 6, "source_connectors": 7}},
"slide_translation": {"recommended_layouts": ["quote", "section", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use warm panels and pull quotes; keep connectors secondary."},
"quality_oracle": {"expected_style_signals": ["book paper background", "warm orange accent", "quiet reading hierarchy"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
},
{
"style_id": "salmon_stamp",
"display_name": "Salmon Stamp",
"group": "Restrained",
"source_token": "IITLwQQ7Vhj0lzbBmhvuvtDWs4c",
"formality": "medium",
"vibe": ["stamp", "training", "emphasis"],
"best_for": ["section.divider", "kpi.big-number", "agenda.structured"],
"avoid_for": ["technical_texture"],
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#F0AE9E", "accent": "#049550", "support": ["#F0AE9E"]},
"shape_language": {"panel_treatment": "stamp-like salmon labels with green accents", "corner_radius": "low", "border_weight": "medium", "texture": "print stamp blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 60, "source_text": 40, "source_shapes": 9, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["section", "kpi", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep stamp labels editable; use green only for key emphasis."},
"quality_oracle": {"expected_style_signals": ["salmon blocks", "black type", "green action accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
},
{
"style_id": "apricot_arc",
"display_name": "Apricot Arc",
"group": "Balanced",
"source_token": "JvQewyVpphPc2MbOD4xuiTD5sbd",
"formality": "medium",
"vibe": ["warm", "motion", "roadmap"],
"best_for": ["timeline.roadmap", "process.flow", "cover.hero"],
"avoid_for": ["monochrome.audit"],
"palette": {"background": "#FFF8EE", "text": "#7A4A33", "muted": "#F9C2BD", "accent": "#C7561E", "support": ["#F69834"]},
"shape_language": {"panel_treatment": "warm arcs and rounded progression panels", "corner_radius": "medium", "border_weight": "medium", "texture": "arc motion geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 76, "source_text": 37, "source_shapes": 23, "source_connectors": 16}},
"slide_translation": {"recommended_layouts": ["timeline", "process", "cover"], "svglide_primitives": ["path", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit arc paths and staged labels; simplify connectors if crowded."},
"quality_oracle": {"expected_style_signals": ["apricot arcs", "warm motion path", "stage progression"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "berry_pop",
"display_name": "Berry Pop",
"group": "Balanced",
"source_token": "JAcFwmlcIh8NKNbJOzGupeTKscg",
"formality": "medium",
"vibe": ["business", "soft", "case"],
"best_for": ["comparison.two-column", "image.story", "dashboard.kpi-grid"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#EDF0FA", "text": "#6E1E3A", "muted": "#C7D2F0", "accent": "#9E2B50", "support": ["#9DB0E8"]},
"shape_language": {"panel_treatment": "soft berry panels with cool support areas", "corner_radius": "medium", "border_weight": "light", "texture": "soft editorial contrast"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 65, "source_text": 40, "source_shapes": 13, "source_connectors": 12}},
"slide_translation": {"recommended_layouts": ["comparison", "image", "dashboard"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use berry for key claims and blue support panels for evidence."},
"quality_oracle": {"expected_style_signals": ["berry headline", "cool soft panels", "balanced contrast"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "bold_poster",
"display_name": "Bold Poster",
"group": "Balanced",
"source_token": "TOwewbtxrhmxNgbzko3udFBvsBd",
"formality": "medium",
"vibe": ["poster", "direct", "high-contrast"],
"best_for": ["cover.hero", "quote.insight", "section.divider"],
"avoid_for": ["dense.table"],
"palette": {"background": "#F5F2EF", "text": "#1C1410", "muted": "#FFFFFF", "accent": "#D8000F", "support": ["#1C1410"]},
"shape_language": {"panel_treatment": "poster blocks with strong red hits", "corner_radius": "low", "border_weight": "heavy", "texture": "flat poster"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 40, "source_shapes": 6, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["cover", "quote", "section"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use large type and one strong red visual anchor; avoid dense micro text."},
"quality_oracle": {"expected_style_signals": ["red poster accent", "black mass", "large type"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 2}}
},
{
"style_id": "checker_bloom",
"display_name": "Checker Bloom",
"group": "Balanced",
"source_token": "S5tEwSOiAhbuvObGnNkuCAUps8d",
"formality": "medium",
"vibe": ["grid", "capability", "modular"],
"best_for": ["dashboard.kpi-grid", "icon_capability_map", "table.visual-summary"],
"avoid_for": ["quote.insight"],
"palette": {"background": "#E8F1DD", "text": "#151515", "muted": "#5E9E4A", "accent": "#2C6EE0", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "checker grid cells with bloom accents", "corner_radius": "medium", "border_weight": "medium", "texture": "modular checker geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 37, "source_shapes": 20, "source_connectors": 12}},
"slide_translation": {"recommended_layouts": ["dashboard", "capability_map", "table"], "svglide_primitives": ["geometric_shape", "icon", "micro_chart"], "fallback_policy": "Build true grid modules; do not reduce to three generic cards."},
"quality_oracle": {"expected_style_signals": ["checker grid", "blue-green accents", "capability modules"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}}
},
{
"style_id": "cobalt_bloom",
"display_name": "Cobalt Bloom",
"group": "Balanced",
"source_token": "Ts2iwaXOuhOBkYbKD80uLjyCsje",
"formality": "medium",
"vibe": ["modern", "tech", "status"],
"best_for": ["architecture.layered", "fake_ui_dashboard", "cover.hero"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#F4EFE9", "text": "#171717", "muted": "#DDA8A2", "accent": "#4746C6", "support": ["#CE968F"]},
"shape_language": {"panel_treatment": "modern cobalt panels with soft pink supports", "corner_radius": "medium", "border_weight": "medium", "texture": "tech editorial blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 56, "source_text": 40, "source_shapes": 11, "source_connectors": 5}},
"slide_translation": {"recommended_layouts": ["architecture", "dashboard", "cover"], "svglide_primitives": ["typography", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt as structural system color and pink as secondary highlight."},
"quality_oracle": {"expected_style_signals": ["cobalt blocks", "modern soft support panels", "technical editorial tone"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "coral",
"display_name": "Coral",
"group": "Balanced",
"source_token": "Ez05w4JTahrMIjb6hjcuJRDpsOy",
"formality": "medium",
"vibe": ["report", "warm", "clear"],
"best_for": ["agenda.structured", "comparison.two-column", "kpi.big-number"],
"avoid_for": ["monochrome.audit"],
"palette": {"background": "#F5F0E8", "text": "#1A1A1A", "muted": "#6B6B6B", "accent": "#E85D5D", "support": ["#D44A4A"]},
"shape_language": {"panel_treatment": "coral emphasis cards with neutral body text", "corner_radius": "medium", "border_weight": "light", "texture": "warm report blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 37, "source_shapes": 23, "source_connectors": 5}},
"slide_translation": {"recommended_layouts": ["agenda", "comparison", "kpi"], "svglide_primitives": ["typography", "geometric_shape", "micro_chart"], "fallback_policy": "Use coral only for claims, values, or section anchors."},
"quality_oracle": {"expected_style_signals": ["coral claim blocks", "neutral report structure", "clear emphasis hierarchy"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
},
{
"style_id": "cut_bloom",
"display_name": "Cut Bloom",
"group": "Balanced",
"source_token": "QHtIwZ4aeha6q8bDkfiuU7TCsgb",
"formality": "medium",
"vibe": ["geometric", "sectioned", "structured"],
"best_for": ["geometric_composition", "architecture.layered", "section.divider"],
"avoid_for": ["dense.table"],
"palette": {"background": "#FFFFFF", "text": "#2E3566", "muted": "#535D9E", "accent": "#F0CB65", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "cut color planes and strong section blocks", "corner_radius": "low", "border_weight": "medium", "texture": "cut-paper geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 40, "source_shapes": 26, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["geometric", "architecture", "section"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Translate angled blocks into safe path geometry, not polygons."},
"quality_oracle": {"expected_style_signals": ["cut planes", "navy structure", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "editorial_forest",
"display_name": "Editorial Forest",
"group": "Balanced",
"source_token": "DeJQwotDFhKucHbVvdUufxepsAe",
"formality": "high",
"vibe": ["research", "editorial", "ecosystem"],
"best_for": ["quote.insight", "comparison.two-column", "paper.explainer"],
"avoid_for": ["brand_system"],
"palette": {"background": "#EFE7D4", "text": "#1A1A17", "muted": "#243A21", "accent": "#E89CB1", "support": ["#2E4A2A"]},
"shape_language": {"panel_treatment": "forest editorial blocks with pink accent", "corner_radius": "low", "border_weight": "light", "texture": "journal-like composition"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 6, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["quote", "comparison", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Let text hierarchy carry the page; use pink accent sparingly."},
"quality_oracle": {"expected_style_signals": ["forest green editorial base", "pink accent", "quiet paper field"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 2}}
},
{
"style_id": "lime_slab",
"display_name": "Lime Slab",
"group": "Balanced",
"source_token": "T3oLwlQLohw8G7b4qfJuFqw9syd",
"formality": "medium",
"vibe": ["energetic", "technical", "slab"],
"best_for": ["cover.hero", "architecture.layered", "process.flow"],
"avoid_for": ["quiet.executive"],
"palette": {"background": "#FFFFF2", "text": "#0A0A05", "muted": "#2F2E25", "accent": "#EEFA79", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "thick slab panels with lime highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "high-energy slab geometry"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 82, "source_text": 43, "source_shapes": 28, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["cover", "architecture", "process"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use lime as high-energy focal surface, but keep text boxes large."},
"quality_oracle": {"expected_style_signals": ["lime slab title", "heavy black structure", "dense technical labels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "linen_cut",
"display_name": "Linen Cut",
"group": "Balanced",
"source_token": "WI3Yw4BakhfaoNbiEfRuCwq0slg",
"formality": "medium",
"vibe": ["business", "linen", "structured"],
"best_for": ["agenda.structured", "table.visual-summary", "comparison.two-column"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#E4D2C4", "text": "#1F1A14", "muted": "#044D99", "accent": "#F61B27", "support": ["#04B24F"]},
"shape_language": {"panel_treatment": "linen panels with sharp color tabs", "corner_radius": "low", "border_weight": "medium", "texture": "woven warm base"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 54, "source_text": 37, "source_shapes": 14, "source_connectors": 3}},
"slide_translation": {"recommended_layouts": ["agenda", "table", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Keep red/green/blue as role colors, not decoration."},
"quality_oracle": {"expected_style_signals": ["linen base", "sharp red/green/blue tabs", "business grid"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "pin_paper",
"display_name": "Pin & Paper",
"group": "Balanced",
"source_token": "NZ5bwYLs1hAat1bHo29uDdhSsJx",
"formality": "medium",
"vibe": ["workshop", "action", "paper"],
"best_for": ["agenda.structured", "process.flow", "quote.insight"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#2A3C99", "accent": "#F1E84E", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "pinned paper notes with blue rails", "corner_radius": "medium", "border_weight": "medium", "texture": "workshop paper"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 66, "source_text": 40, "source_shapes": 21, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use pinned note panels and explicit action labels; avoid showing the preset name."},
"quality_oracle": {"expected_style_signals": ["pinned paper cards", "blue rails", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "raw_grid",
"display_name": "Raw Grid",
"group": "Balanced",
"source_token": "Z6CbwF2oPhvXQ8biBRnufsyksqf",
"formality": "medium",
"vibe": ["technical", "dense", "grid"],
"best_for": ["dashboard.kpi-grid", "table.visual-summary", "architecture.layered"],
"avoid_for": ["cover.hero"],
"palette": {"background": "#F5F5F5", "text": "#0A0A0A", "muted": "#333333", "accent": "#F2D4CF", "support": ["#FFFFFF", "#E5EDD6"]},
"shape_language": {"panel_treatment": "grid panels with dense labels", "corner_radius": "low", "border_weight": "medium", "texture": "explicit grid geometry"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 98, "source_text": 52, "source_shapes": 44, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["dashboard", "table", "architecture"], "svglide_primitives": ["geometric_shape", "typography", "texture", "annotation"], "fallback_policy": "Keep native text and basic shapes; fallback only for over-dense visual tables."},
"quality_oracle": {"expected_style_signals": ["grid geometry", "dense labels", "muted panels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
},
{
"style_id": "riptide_cobalt",
"display_name": "Riptide Cobalt",
"group": "Balanced",
"source_token": "Qpyow1AnZhU763b3d51ur42csZd",
"formality": "medium",
"vibe": ["technology", "flow", "cobalt"],
"best_for": ["path_flow", "architecture.layered", "fake_ui_dashboard"],
"avoid_for": ["quiet.executive"],
"palette": {"background": "#FDF0E0", "text": "#1A2240", "muted": "#2741C0", "accent": "#375DFE", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "cobalt panels with flowing technical emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "blue flow geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 69, "source_text": 41, "source_shapes": 26, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["path", "architecture", "dashboard"], "svglide_primitives": ["path", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt flow paths and layered blocks; do not overdo connectors."},
"quality_oracle": {"expected_style_signals": ["cobalt flow", "cream contrast", "layered tech panels"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}}
},
{
"style_id": "soft_editorial",
"display_name": "Soft Editorial",
"group": "Balanced",
"source_token": "T7A4w3ioHhgOjLbuFywuqZwbsUg",
"formality": "medium",
"vibe": ["soft", "editorial", "summary"],
"best_for": ["quote.insight", "agenda.structured", "comparison.two-column"],
"avoid_for": ["technical_texture"],
"palette": {"background": "#ECE9DC", "text": "#1C1A17", "muted": "#E7C6AD", "accent": "#E2A8CE", "support": ["#C9DA4F"]},
"shape_language": {"panel_treatment": "soft editorial fields with small text fragments", "corner_radius": "medium", "border_weight": "light", "texture": "gentle magazine layout"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 40, "source_shapes": 5, "source_connectors": 6}},
"slide_translation": {"recommended_layouts": ["quote", "agenda", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Split text into readable editorial fragments; keep labels concise."},
"quality_oracle": {"expected_style_signals": ["soft editorial colors", "small text fragments", "gentle accent palette"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
},
{
"style_id": "violet_marker",
"display_name": "Violet Marker",
"group": "Balanced",
"source_token": "HISlwkosLhVgFVb8y3KucknAslb",
"formality": "medium",
"vibe": ["marker", "training", "concept"],
"best_for": ["section.divider", "process.flow", "icon_capability_map"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#666463", "accent": "#C5A1FF", "support": ["#CFEE30"]},
"shape_language": {"panel_treatment": "marker-like labels with violet and lime accents", "corner_radius": "medium", "border_weight": "medium", "texture": "hand-highlighted marker blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["section", "process", "capability_map"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use violet/lime marker tags for concepts, not all text."},
"quality_oracle": {"expected_style_signals": ["violet marker accent", "lime support", "concept labels"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "blockframe",
"display_name": "BlockFrame",
"group": "Bold",
"source_token": "Qu3Lwf4VRhyYzWbjI1xuBn5UsEc",
"formality": "low",
"vibe": ["showcase", "blocky", "colorful"],
"best_for": ["cover.hero", "brand_system", "image.story"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#C0F7FE", "accent": "#F7CB46", "support": ["#FE90E8", "#99E885", "#FFDC8B"]},
"shape_language": {"panel_treatment": "strong colored block frames", "corner_radius": "low", "border_weight": "heavy", "texture": "graphic frame system"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 75, "source_text": 41, "source_shapes": 31, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["cover", "brand", "image"], "svglide_primitives": ["geometric_shape", "typography", "brand_system"], "fallback_policy": "Use block frames as visual identity; keep content hierarchy simple."},
"quality_oracle": {"expected_style_signals": ["multi-color frames", "black outlines", "bold block identity"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}}
},
{
"style_id": "burst_panel",
"display_name": "Burst Panel",
"group": "Bold",
"source_token": "IUVZwJZirhaIZQbxkfnuj2jPskh",
"formality": "low",
"vibe": ["poster", "burst", "dense"],
"best_for": ["cover.hero", "dashboard.kpi-grid", "brand_system"],
"avoid_for": ["quiet.executive"],
"palette": {"background": "#FFFAF0", "text": "#1E1E1E", "muted": "#FBD65A", "accent": "#FFA76D", "support": ["#CFACE8", "#AAE4BA"]},
"shape_language": {"panel_treatment": "bursting panels with many colored regions", "corner_radius": "medium", "border_weight": "medium", "texture": "poster panel collage"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 89, "source_text": 50, "source_shapes": 37, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["cover", "dashboard", "brand"], "svglide_primitives": ["geometric_shape", "typography", "micro_chart"], "fallback_policy": "Preserve high-energy panels but cap text boxes to avoid crowding."},
"quality_oracle": {"expected_style_signals": ["burst panels", "warm poster palette", "high information blocks"], "warning_thresholds": {"text_boxes_max": 30, "accent_colors_max": 5}}
},
{
"style_id": "confetti_wedge",
"display_name": "Confetti Wedge",
"group": "Bold",
"source_token": "YDyLwTCiHhKJ0NbuP89uHUV8sNh",
"formality": "low",
"vibe": ["playful", "light", "wedge"],
"best_for": ["section.divider", "image.story", "quote.insight"],
"avoid_for": ["dense.table"],
"palette": {"background": "#F4F8FB", "text": "#000000", "muted": "#3A3C3E", "accent": "#62C0A5", "support": ["#F8BED4", "#65C8CD"]},
"shape_language": {"panel_treatment": "confetti wedges and light motion shapes", "corner_radius": "medium", "border_weight": "light", "texture": "playful wedge accents"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 37, "source_shapes": 13, "source_connectors": 2}},
"slide_translation": {"recommended_layouts": ["section", "image", "quote"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Use a few wedge paths as memory points; avoid confetti over text."},
"quality_oracle": {"expected_style_signals": ["wedge accents", "light playful palette", "simple panel structure"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 4}}
},
{
"style_id": "court_press",
"display_name": "Court Press",
"group": "Bold",
"source_token": "JH9owJl4sh0PeIbUm9SujYCTsTc",
"formality": "low",
"vibe": ["sports", "team", "operations"],
"best_for": ["process.flow", "timeline.roadmap", "metaphor_loop"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#F2EFE6", "text": "#2F4224", "muted": "#66914C", "accent": "#DA9EB7", "support": ["#FFFFFF"]},
"shape_language": {"panel_treatment": "court-like lanes and team panels", "corner_radius": "medium", "border_weight": "medium", "texture": "court lane geometry"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 61, "source_text": 37, "source_shapes": 11, "source_connectors": 13}},
"slide_translation": {"recommended_layouts": ["process", "timeline", "loop"], "svglide_primitives": ["connector_flow", "path", "geometric_shape"], "fallback_policy": "Use lanes and flows to encode motion; keep sports metaphor subtle."},
"quality_oracle": {"expected_style_signals": ["court lanes", "green team palette", "pink accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
},
{
"style_id": "crayon_stack",
"display_name": "Crayon Stack",
"group": "Bold",
"source_token": "JjKIwig1vhnXq7bbeCfutuKFseh",
"formality": "low",
"vibe": ["creative", "playful", "stacked"],
"best_for": ["cover.hero", "icon_capability_map", "brand_system"],
"avoid_for": ["executive.audit"],
"palette": {"background": "#FFFFFF", "text": "#222222", "muted": "#8A2E43", "accent": "#FF472B", "support": ["#D3FE79", "#FBB8FD", "#2A8F6D", "#7E90FC"]},
"shape_language": {"panel_treatment": "stacked crayon color bars", "corner_radius": "medium", "border_weight": "heavy", "texture": "handmade colorful blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 37, "source_shapes": 8, "source_connectors": 3}},
"slide_translation": {"recommended_layouts": ["cover", "capability_map", "brand"], "svglide_primitives": ["geometric_shape", "icon", "typography"], "fallback_policy": "Use colorful stacks for categories; avoid making every label a different color."},
"quality_oracle": {"expected_style_signals": ["crayon color stack", "playful strong accents", "bold category blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 6}}
},
{
"style_id": "grove_block",
"display_name": "Grove Block",
"group": "Bold",
"source_token": "Mq2XwYKDEhbnRmbIxAfuZGCQsOb",
"formality": "medium",
"vibe": ["ecosystem", "bold", "result"],
"best_for": ["kpi.big-number", "metaphor_loop", "process.flow"],
"avoid_for": ["quiet.editorial"],
"palette": {"background": "#FCF6F1", "text": "#01623F", "muted": "#008248", "accent": "#FCC715", "support": ["#F7F1EC", "#F6BDDA"]},
"shape_language": {"panel_treatment": "bold green blocks with yellow emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "organic block system"},
"density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 44, "source_shapes": 20, "source_connectors": 9}},
"slide_translation": {"recommended_layouts": ["kpi", "loop", "process"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use green blocks for system stages and yellow for outcome emphasis."},
"quality_oracle": {"expected_style_signals": ["bold green blocks", "yellow result accent", "ecosystem grouping"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 3}}
},
{
"style_id": "mint_brut",
"display_name": "Mint Brut",
"group": "Bold",
"source_token": "BMe4wBmwlhGfNCbooCvuShTfs4v",
"formality": "low",
"vibe": ["brutalist", "technical", "mint"],
"best_for": ["architecture.layered", "process.flow", "cover.hero"],
"avoid_for": ["quiet.training"],
"palette": {"background": "#FFFBF3", "text": "#000000", "muted": "#D0FDE4", "accent": "#70F0A8", "support": ["#F888C8", "#F0DE4E"]},
"shape_language": {"panel_treatment": "brutalist mint panels with loud highlights", "corner_radius": "low", "border_weight": "heavy", "texture": "brutalist blocks"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 75, "source_text": 38, "source_shapes": 23, "source_connectors": 11}},
"slide_translation": {"recommended_layouts": ["architecture", "process", "cover"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use heavy frames and mint surfaces; keep text large and sparse."},
"quality_oracle": {"expected_style_signals": ["mint brutalist panels", "black outlines", "pink/yellow highlights"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 4}}
},
{
"style_id": "neo_grid_bold",
"display_name": "Neo-Grid Bold",
"group": "Bold",
"source_token": "WwstwfAj1hIZXhbDwdDuwdpDsBh",
"formality": "medium",
"vibe": ["neo-grid", "technical", "high-contrast"],
"best_for": ["technical_texture", "architecture.layered", "fake_ui_dashboard"],
"avoid_for": ["paper.explainer"],
"palette": {"background": "#F5F4EF", "text": "#0A0A0A", "muted": "#8A8A85", "accent": "#E6FF3D", "support": ["#0A0A0A"]},
"shape_language": {"panel_treatment": "black grid rails with neon highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "explicit neo grid"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 12, "source_connectors": 17}},
"slide_translation": {"recommended_layouts": ["technical_texture", "architecture", "dashboard"], "svglide_primitives": ["texture", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit grid lines/dots; do not rely on SVG pattern."},
"quality_oracle": {"expected_style_signals": ["black grid structure", "neon lime focus", "technical rails"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
},
{
"style_id": "riso_brut",
"display_name": "Riso Brut",
"group": "Bold",
"source_token": "Mztnwj2ouhot6VbtbwMuWN4SsRb",
"formality": "low",
"vibe": ["riso", "poster", "multicolor"],
"best_for": ["brand_system", "cover.hero", "image.story"],
"avoid_for": ["legal.audit"],
"palette": {"background": "#EFE9D9", "text": "#0F0F0F", "muted": "#136636", "accent": "#F5C518", "support": ["#1F8A4C", "#E85A1F", "#F06CA8", "#D14E8B"]},
"shape_language": {"panel_treatment": "riso-print blocks and overlapping color plates", "corner_radius": "low", "border_weight": "medium", "texture": "riso poster plates"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 71, "source_text": 38, "source_shapes": 29, "source_connectors": 4}},
"slide_translation": {"recommended_layouts": ["brand", "cover", "image"], "svglide_primitives": ["geometric_shape", "typography", "image_overlay"], "fallback_policy": "Use flat color plates; avoid opacity-heavy overlaps unless preflight accepts them."},
"quality_oracle": {"expected_style_signals": ["riso color plates", "poster energy", "cream paper"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 6}}
},
{
"style_id": "specimen_bold",
"display_name": "Specimen Bold",
"group": "Bold",
"source_token": "BNFmwKX6OhWRfbb3SX4uRoHesD6",
"formality": "medium",
"vibe": ["specimen", "concept", "modular"],
"best_for": ["icon_capability_map", "comparison.two-column", "section.divider"],
"avoid_for": ["paper.explainer"],
"palette": {"background": "#F3F3F3", "text": "#2E302E", "muted": "#30A1E5", "accent": "#3EC06A", "support": ["#FFFFFF", "#FBEF4A"]},
"shape_language": {"panel_treatment": "specimen cards with multiple focus markers", "corner_radius": "medium", "border_weight": "medium", "texture": "sample-card layout"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 57, "source_text": 37, "source_shapes": 8, "source_connectors": 12}},
"slide_translation": {"recommended_layouts": ["capability_map", "comparison", "section"], "svglide_primitives": ["icon", "geometric_shape", "connector_flow"], "fallback_policy": "Use specimen cards for concepts and blue/green markers for relationships."},
"quality_oracle": {"expected_style_signals": ["specimen cards", "blue/green markers", "modular concept blocks"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 4}}
},
{
"style_id": "stencil_tablet",
"display_name": "Stencil & Tablet",
"group": "Bold",
"source_token": "J1BKw4SxhhC3kTbD77auLJ5UsRc",
"formality": "medium",
"vibe": ["stencil", "method", "action"],
"best_for": ["quote.insight", "process.flow", "agenda.structured"],
"avoid_for": ["quiet.editorial"],
"palette": {"background": "#F4EFE0", "text": "#0A0A0A", "muted": "#E2DCC9", "accent": "#D8A93B", "support": ["#EE7A2E", "#C73B7A", "#2D7E73"]},
"shape_language": {"panel_treatment": "stencil panels and tablet-like blocks", "corner_radius": "low", "border_weight": "medium", "texture": "stencil print panels"},
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 7, "source_connectors": 5}},
"slide_translation": {"recommended_layouts": ["quote", "process", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use stencil panels for method/action emphasis; keep decorative colors role-bound."},
"quality_oracle": {"expected_style_signals": ["stencil panels", "tablet blocks", "gold/orange/magenta accents"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}}
}
]
}

View File

@@ -0,0 +1,89 @@
# SVGlide Style Presets
`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets. This Markdown file is only a human-readable guide.
## Boundary
Style presets are not slide templates. They do not replace `visual_recipe`, `renderer_id`, or the page semantic plan.
- `visual_recipe`: explains the page structure and SVG-native value, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`.
- `style_preset`: selects the visual language, palette, panel treatment, connector density, label density, and texture.
- `style_system`: records how the selected preset is translated into the current deck.
Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content.
## Required Plan Fields
For `output_mode="svglide-svg"`, the deck plan must include:
```json
{
"style_preset": "raw_grid",
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
"style_system": {
"palette": {
"background": "#F5F5F5",
"text": "#0A0A0A",
"accent": "#F2D4CF"
},
"typography": "strong title, readable native text labels",
"background_strategy": "muted grid panels with one stable background family",
"motif": "dense grid panels with restrained accent labels"
}
}
```
Each slide must also include:
```json
{
"visual_recipe": "path_flow",
"visual_signature": "curved route path with explicit stage annotations",
"svg_effects": ["path", "connector_flow", "typography"],
"svg_primitives": ["path", "annotation"],
"required_primitives": ["path", "annotation"]
}
```
Use `visual_plan` as a nested container when useful. `svg_preflight.py` accepts both the nested shape and the existing flat fields; nested `visual_plan` wins when both are present.
## Selection Rule
1. Choose intensity first.
- `Restrained`: serious, quiet, institutional, text-first decks.
- `Balanced`: default for business, technical, training, and explanatory decks.
- `Bold`: posters, showcases, event material, playful explainers, high-energy pages.
2. Match the user's tone and topic.
3. Keep the semantic plan stable. Switching from `raw_grid` to `reading_room` should change visual treatment, not invent new facts or rearrange the story.
4. Pick page-level overrides only for cover, section divider, or poster-like moments. Most slides should inherit the deck-level `style_preset`.
## SVGlide-Safe Translation
Translate style into supported SVG primitives:
- Palette -> explicit `fill`, `stroke`, and text colors.
- Panel treatment -> `rect`, `path`, and grouped layout boxes.
- Connector density -> explicit `line` or supported `path`; do not rely on `marker` or key-path `stroke-dasharray`.
- Texture -> repeated native `line`, `circle`, or `rect`; do not rely on `<pattern>` as the only effect.
- Image overlay -> real `<image slide:role="image">` plus explicit shape masks/overlays when needed.
Unsafe effects such as `filter`, `mask_clip`, `pattern`, `symbol`, `stroke_dasharray`, and `image_opacity` may appear in the plan only when a safe rewrite or fallback is declared.
## Quality Gates
Before calling `slides +create-svg`, run:
```bash
python3 skills/lark-slides/scripts/svg_preflight.py \
--plan .lark-slides/plan/<deck-id>/slide_plan.json \
--input .lark-slides/plan/<deck-id>/pages/page-001.svg
```
The preflight checks:
- preset exists in `style-presets.json`;
- `style_system` has palette, typography, background strategy, and motif;
- each page declares `visual_signature` and `svg_effects`;
- unsafe effects have fallback or rewrite notes;
- declared effects and primitives are present in the SVG source;
- visible slide text does not leak preset names, source tokens, prompts, tool names, or local file paths.

View File

@@ -0,0 +1,105 @@
# SVGlide 审美 Review
这份文档用于本地 SVG/HTML preview 生成之后、调用 `slides +create-svg` 之前。
它是从以下审美评分标准中提炼出的短版执行清单:
`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/svg_aesthetic_rubric.md`.
这份 review 补充 `svg_preflight.py`。Preflight 负责确定性的协议、plan 和
bbox 问题;这份清单负责需要人工或截图判断的渲染后视觉质量问题。
## 必须执行的 Review 流程
1. 生成本地 SVG 文件,并在条件允许时生成本地 `preview.html`
2. 运行 `svg_preflight.py --plan ... --input ...`;先修复所有 error。
3. 打开或检查 preview。必须审查所有页面不只看封面。
4. 重复出现的版式问题要修生成器或 source SVG不能只改 `slide_plan.json`
5. live 创建前重新运行 preflight 和 preview。
不要用 preview review 替代 live readback。服务端转换后仍可能改变文本框、
图片 token、path bounds 和不支持的效果。
## 阻断性视觉问题
调用 live API 前必须修复这些问题:
| 问题 | 处理方式 |
|---|---|
| 文本重叠、文本容器溢出、标题被裁切 | 重新生成 layout boxes 或减少文本;不要只是整体缩小 |
| badge、pill、章节标签或页码标签贴住/压住标题 | 把 badge 移出标题块,或保留至少 12-16px 垂直间距 |
| 装饰线或色带压迫标题 | 把线移到标题区上方,或下移标题,保留呼吸感 |
| 主体内容超出 `960 x 540` 或 safe area | 按 960x540 画布重新计算坐标 |
| 浅色图片/背景上的低对比文本 | 增加实色承载底、overlay或切换文字颜色 |
| 空图片框或 preview 破图 | live 创建前替换资产或使用视觉 fallback |
| 页面缺少视觉焦点 | 围绕一个主导数字、图解、图片、路径或标题重建页面 |
| 页面只是普通卡片/bullet缺少 SVG 优势 | 选择更合适的 `visual_recipe`,或不要走 SVG 路线 |
| 同类版式问题在多页重复出现 | 修共享生成规则,然后重新生成受影响页面 |
## Issue 严重级别
在 preview notes 和最终验证记录中使用这些级别:
| 级别 | 含义 | 处理方式 |
|---|---|---|
| P0 | 不应该 live 创建 | 在 `slides +create-svg` 前修复或重新生成 |
| P1 | 可以渲染,但用户可见质量明显低于目标 | 交付前修复;只有用户明确接受草稿时才继续 |
| P2 | 小幅打磨项或残余风险 | 记录下来,有时间再修 |
默认映射:
- P0preflight error、不安全 SVG、破图/空图、画布裁切、关键文本裁切或重叠、对比度不可读、必需资产缺失、不支持视觉缺少 fallback。
- P1焦点弱、布局骨架重复、装饰/标题拥挤、视觉层级弱、图表/图解意图不匹配、可见 SVG 优势弱。
- P2轻微对齐差异、小的颜色不一致、非关键来源元数据 warning、只影响打磨的间距问题。
## 评分标准
使用 0-100 分。用户可见 deck 的默认目标是 `>= 75`
低于 `65`live 创建前必须重新生成或修复。
| 维度 | 权重 | 好结果 |
|---|---:|---|
| 沟通匹配度 | 15 | 页面类型和视觉形式匹配用户意图 |
| 视觉层级 | 15 | 2 秒内能看到唯一焦点 |
| 布局稳定性 | 15 | 网格、间距、对齐和 safe area 一致 |
| 可读性 | 15 | 字号、行长、对比度和换行可读 |
| 颜色纪律 | 10 | 强调色数量少,且语义一致 |
| 数据/图解完整性 | 10 | 图表、流程和图解诚实表达关系 |
| 风格一致性 | 8 | 图标、圆角、线宽、阴影和 motif 像同一套 deck |
| SVG 优势 | 7 | 页面明显受益于 path、texture、chart geometry、flow 或 overlay |
| 来源/资产可追溯 | 5 | 使用外部参考和 preview 资产时有记录 |
## Review 问题
每页都问这些问题:
- 这一页的一句话 takeaway 是什么?
- 第一眼落点在哪里,是否就是预期的 `visual_focal_point`
- 视线顺序是否符合 title -> focal visual -> evidence -> detail
- 有没有 badge、线条、水印、标签或缩略图挤压文本
- 页面是否使用了 SVG-native 结构,还是只有普通盒子和文本?
- 如果这一页变成普通 XML/PPT 卡片布局,会损失什么?
- 图表/流程/表格选择是否适合它要表达的关系?
- 颜色和强调方式是否和整套 deck 保持一致?
## 修复优先级
1. 布局正确性画布、safe area、重叠、溢出、裁切。
2. 可读性:对比度、字号、行长、文本框高度充足。
3. 层级:一个焦点对象、清晰标题、支撑细节降级。
4. SVG 优势path/flow/chart/icon/texture/image overlay 真实存在。
5. Deck 节奏:避免只换文案却重复同一骨架。
6. 资产/来源治理preview 资产可见,来源元数据存在。
## 可接受的输出记录
报告验证结果时,明确说明检查过什么:
```text
SVG preview review:
- preflight: passed / fixed errors first
- preview_path: .lark-slides/plan/<deck-id>/preview.html
- preview: checked all N pages for overlap, safe area, readability, and repeated layout issues
- visual_score: 82 / threshold 75
- issue_ids: none / [P1 visual.layout.decorative_line_title_pressure page=3]
- action: create_live / repair_and_rerun / draft_only
- remaining risk: live readback may still change text bbox or unsupported effects
```

View File

@@ -0,0 +1,169 @@
# SVGlide SVG Protocol
最小模板:
```xml
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
slide:contract-version="svglide-authoring-contract/v1"
width="960"
height="540"
viewBox="0 0 960 540"
>
<rect slide:role="shape" x="60" y="60" width="240" height="135" fill="#E8EEF8" />
<foreignObject slide:role="shape" slide:shape-type="text" x="90" y="98" width="240" height="60">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:20px;font-weight:700;color:#1F2937;line-height:1.2">SVGlide</div>
</foreignObject>
<image slide:role="image" href="@./hero.png" x="390" y="60" width="240" height="135" />
</svg>
```
## 必须满足
- root 必须是非 namespaced 的 `<svg>`,不能是 `<svg:svg>`
- root 必须声明 `xmlns:slide="https://slides.bytedance.com/ns"`
- root 必须包含 `slide:role="slide"`
- root 应包含 `slide:contract-version="svglide-authoring-contract/v1"`,用于标识这是 SVGlide authoring contract 输入,而不是普通 SVG。
- 可渲染元素必须有对应 `slide:role`shape 使用 `slide:role="shape"`,图片使用 `slide:role="image"`
- `<g>` 和嵌套 `<svg>` 可以作为容器,用于继承样式和 transform容器内真正渲染的元素仍必须声明 `slide:role`
- `slide:role="shape"` 目前只支持 `rect``ellipse``circle``line``path``foreignObject`
- 文本使用文本型 shape`<foreignObject slide:role="shape" slide:shape-type="text">...</foreignObject>`
- 图片使用 `<image slide:role="image" href="file_token">`;本地占位符写成 `href="@./image.png"`
- `<defs>``<style>` 会被服务端解析/跳过输出;支持嵌套在 `g` / 嵌套 `svg` 容器中。
- CLI 注入的 transport metadata `<metadata data-svglide-assets="true">` 会被跳过输出但用于传输图片元数据。
## 坐标系与画布
- 当前 `slides +create-svg` 新建的 Lark Slides presentation 回读画布通常是 `960 x 540`。生成 SVG deck 时默认使用 `width="960" height="540" viewBox="0 0 960 540"`,不要默认用 `1280 x 720`
- 服务端不会保证把 `viewBox="0 0 1280 720"` 自动缩放到 `960 x 540`。如果用 1280x720 设计,必须在提交前整体换算到目标画布,或在回读 XML 后验证没有越界。
- 生成时为所有主体元素预留安全区,建议 `x >= 48``y >= 40``right <= 912``bottom <= 500`。全屏背景可以铺满 `0,0,960,540`,但主体文字、图表和卡片仍应留在安全区内。
- 回读 XML 后必须检查主体元素边界:非背景元素的 `topLeftX + width <= 960``topLeftY + height <= 540`。任何页面越界都视为生成失败,需要重排或缩放后重建。
## 几何必填属性
SVGlide leaf shape 必须显式写出服务端建模所需的几何属性,不依赖 SVG 默认值。缺失这些属性通常会被服务端包装成 `shape missing required attribute` 或 generic invalid param。
| Element | Required attributes |
|---------|---------------------|
| `rect slide:role="shape"` | `x`, `y`, `width`, `height` |
| `foreignObject slide:role="shape" slide:shape-type="text"` | `x`, `y`, `width`, `height` |
| `image slide:role="image"` | `href`, `x`, `y`, `width`, `height` |
| `circle slide:role="shape"` | `cx`, `cy`, `r` |
| `ellipse slide:role="shape"` | `cx`, `cy`, `rx`, `ry` |
| `line slide:role="shape"` | `x1`, `y1`, `x2`, `y2` |
| `path slide:role="shape"` | `d` |
这些属性即使取值为 `0` 也要写出来。例如背景图必须写成:
```xml
<image slide:role="image" href="@./background.jpg" x="0" y="0" width="960" height="540" />
```
CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px` 长度,例如 `0``1280``320.5``80px`。不要使用 `%``em``rem``calc(...)` 或省略单位后依赖 SVG 默认值。服务端可能会对部分非法几何值降级为 `0` 并给 warning但正式生成应在 CLI 侧提前修正。
## 当前支持的 SVG 子集
- Shape: `rect``ellipse``circle``line``path``foreignObject`
- Container: `g`、嵌套 `svg`
- Definitions: `defs` 内的 `linearGradient``radialGradient``filter/feDropShadow`;支持嵌套 `defs` 和 gradient `href` / `xlink:href` 继承。
- CSS: tag、`.class``#id``.a.b``tag.class` 等简单 selector支持 specificity 和 source order复杂 selector、media query、伪类会被忽略。
- Paint: `fill``stroke``stroke-width``opacity``fill-opacity``stroke-opacity``stroke-dasharray``stroke-linecap``stroke-linejoin`
- Gradient: `stop-color` / `stop-opacity` 可来自属性、inline style 或 CSS`gradientTransform``spreadMethod`、focal 点等复杂能力会被近似或忽略。
- Effects: 支持 `filter="url(#...)"` 指向的 `feDropShadow`、CSS `filter: drop-shadow(...)`、以及首层 `box-shadow`;多层 shadow、spread、inset 会被近似或忽略。
- Transform: `translate``scale``matrix``rotate`transform 参数应写数字或 `px`,复杂 transform 会被近似或忽略。
- Path: 只使用 `M/L/H/V/C/Q/Z`CLI 会拒绝 arc `A`、smooth curve `S/T` 和其他未知命令。
- Text: `foreignObject slide:shape-type="text"` 内支持常见 XHTML 文本标签、`br` 和基础文字样式。
## SVG-native 效果的 SVGlide-safe 写法
视觉参考图、浏览器 SVG demo 或 `svglide-visual-effects-gallery.html` 只能作为效果方向,不能直接当作 `slides +create-svg` 输入。生成器必须把浏览器 SVG 能力改写为当前 SVGlide 支持面:
| 浏览器 SVG 常见写法 | SVGlide-safe 写法 |
|---|---|
| 根级 `<text>` / 普通 SVG text | `foreignObject slide:role="shape" slide:shape-type="text"`,并显式写 `font-size``font-weight``color``line-height` |
| `<polygon>` / `<polyline>` | 改成 `path slide:role="shape"`,只使用 `M/L/H/V/C/Q/Z` |
| `<marker>` 箭头 | 用独立三角形 `path` 或短 line + arrowhead path 显式绘制 |
| `<pattern>` 网格、点阵、纹理 | 用重复的 `line``circle``rect` 显式铺排;不要依赖 pattern 展开 |
| `mask` / `clipPath` 大字裁切 | 用大字描边、深色/渐变背板、半透明 shape overlay 或裁切后的本地图片替代 |
| 多层 `filter`、blur、glow | 用多层半透明 circle/rect/path 模拟光晕;仅把简单 drop-shadow 当增强,不当核心表达 |
| `stroke-dasharray` 关键路线 | 用短 line segment 或 filled dot markers 手工排布;关键流程不要只靠虚线;带 `route` / `path` / `flow` / `loop` / `timeline` / `rail` 等语义的虚线会被 preflight 视为错误 |
| `<image opacity="...">` | MVP preflight 只 warning高保真场景应预合成到图片或在图片上方加半透明 `rect slide:role="shape"` |
| iconfont / 外链 SVG 图标 | 用 SVGlide-safe path/line/rect/circle 组合本地绘制,或先转成受支持的本地图片资产 |
每个 SVG 页面应通过 `visual_recipe` 证明自己值得使用 SVG要么有强视觉主标题要么有路径/流向/隐喻/标注/图标系统/微图表/纹理/仪表盘等 SVG-native 结构。只有 `rect + foreignObject` 的普通卡片页应优先走 XML/SXSD。
文本样式应使用 parser 友好的显式 CSS 属性,例如 `font-size``font-weight``font-family``color``line-height``text-align``letter-spacing`。不要依赖 `font:` shorthand、复杂 flex 布局或浏览器默认样式来表达关键字号、加粗和行距;这些在转换到 SXSD/XML 时可能降级为默认样式。
白色或接近白色的文字必须完整落在深色 shape 承载底上;如果标题跨到浅色图片、白色蒙层或白底,生成器应扩大深色底、加背板/遮罩,或改用深色文字。圆形/椭圆节点内只放短标签,解释句、指标和说明放到独立 callout、legend 或机制表中。
生成 live smoke 或跨 lane 验证用 SVG 时,颜色优先写成 hex/rgb 加独立透明度属性,例如 `fill="#0F172A" opacity="0.72"``stroke="#38BDF8" stroke-opacity="0.8"`。不要在首轮验证里大量依赖 `rgba(...)` 作为 SVG leaf 的 `fill` / `stroke` 值;不同 server lane 的 paint 解析能力可能不一致hex + opacity 更容易定位问题。渐变仍按 XML 协议要求使用 `rgba(...)` 停靠点。
图片透明度当前不是稳定协议面:`<image opacity="...">` 在 SVG 输入中会通过 CLI 传给服务端,但转换后的 Slides XML `<img>` 不一定保留 alpha。MVP 阶段 preflight 只 warning生成器不得在高保真页面依赖 image opacity要么把淡化效果预合成到本地图片文件要么用一个半透明 `rect slide:role="shape"` 覆盖在图片上方。shape opacity 会转换为 Slides XML `alpha`,比 image opacity 更稳定。
圆形和椭圆描边宽度也不是稳定协议面:`circle` / `ellipse``stroke-width` 可能在 readback 中降级。关键圆环请用两层填充圆/椭圆模拟,或改用 path/rect普通细描边可以保留但需要视觉回读确认。
虚线描边也不是稳定协议面:`stroke-dasharray`,尤其是自定义 path 上的虚线闭环,可能在 readback 中降级。关键流程线、路线图和闭环图用短 line segment 或 filled dot markers 显式绘制;带 `route``path``flow``loop``timeline``rail` 等语义的 dashed path 会被 `svg_preflight.py` 作为 error 拦截。普通装饰虚线也需要 live readback 复核。
## 不支持
- 不要把普通 SVG 直接交给 `+create-svg`CLI 不会自动补齐 SVGlide 协议。
- 不支持缺少 role 的可渲染元素,例如 `<rect .../>`;必须写成 `<rect slide:role="shape" .../>`
- 不要把 `<g>` 当作可渲染 shape`<g>` 只是容器,实际 `rect``path``foreignObject``image` 等子元素仍需各自声明 `slide:role`
- 不支持根级 `<text slide:role="text">`;用 `foreignObject + slide:shape-type="text"`
- 不要在 `<image>` 上保留 `xlink:href`CLI 会统一输出 canonical `href`
- Preview/MVP 阶段允许 http(s) 或 data URL 图片通过 preflight warning用于快速验证丰富视觉live 转换和 readback 可见性不保证,必须回读确认。正式交付优先下载到本地并让 CLI 上传,或用 `--assets` 提供已上传 file token。
- `slides +create-svg` MVP 不支持指定 `beforeSlideBlockID` 插入到某一页前;它创建新 presentation 后按 `--file` 顺序追加。
这些能力依赖 slide server SVGlide parser 新版本。如果 BOE/线上未部署对应 server 分支CLI 放行后仍可能收到服务端 `SVGLIDE_ERROR_JSON` 或 generic invalid param。
## 图片与 Metadata
SVG deck 默认应使用真实图片资产,不要为了规避上传链路而全程用纯矢量 shape 冒充配图。Preview 阶段图片是拉开 SVGlide 和 XML 生成差距的关键能力:宣传、产品、品牌、案例、教学和视觉展示型 deck 应优先根据用户 query、deck 主题和页面标题去网络检索并拉取强相关图片,再包含封面/半出血主视觉/案例场景/产品截图/材质纹理/图鉴图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路完全不可用时,才退回纯矢量方案,并在结果中说明原因。
图片资产采用双模式:
- **Preview mode**:版权/授权不是阻断项。SHOULD 先从用户 query、deck 标题、章节标题和 page takeaway 生成图片检索词,去网络检索并拉取主题强相关图片;也可以使用公开可访问图片 URL、搜索图片、新闻/历史/艺术/科普图片、官网截图、产品截图、网页截图、材质纹理或 AI 生成图作为视觉占位。必须记录 `retrieval_query``source_url` 或生成方式,并把 `license` 写成 `preview_unverified`。不要使用明显不适当素材、敏感肖像,或会造成商业背书误导的 logo/商标。
- **Production mode**:正式交付必须替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。推荐来源包括 Unsplash、Pexels、Pixabay、Openverse、Wikimedia Commons、The Met Open Access、Smithsonian Open Access 和 NASA Image and Video Library但每张图都应检查具体 license、署名和第三方权利。
MVP 阶段素材清单不完整只作为 warning但生成 deck 时仍应在素材清单或 README 中记录图片检索词、图片来源、授权/许可类型、下载 URL 或生成方式、是否需要署名;无法确认授权时应显式标记风险并在正式交付前替换。
当 SVG source 使用 `<image>` 时,对应 slide plan 应尽量有 `asset_contract`,并至少包含:
```json
{
"mode": "preview",
"source_type": "public_url | web_search_preview | screenshot | Unsplash | Pexels | procedural | ai_generated | user_provided | owned",
"retrieval_query": "topic-specific image query derived from user query and page topic",
"license": "preview_unverified",
"local_path": "@./assets/hero.jpg",
"href": "https://example.com/hero.jpg",
"usage_page": 1,
"source_url": "https://...",
"retrieved_at": "2026-06-08",
"generated_by": "optional when source_type is procedural/ai_generated",
"replacement_required": true
}
```
无图片页可以写 `"asset_contract": "none_required"`。如果 SVG source 检测到 image primitive`asset_contract` 缺少检索词、来源、许可、本地路径或使用页MVP 阶段 preflight 只 warningpreview 中可用 `license=preview_unverified` 明确标记,正式交付仍应补齐或替换为来源清晰的图片资产。
`slides +create-svg` 会把 `<image href="@./image.png">` 上传为 file token并注入
```xml
<metadata data-svglide-assets="true">
<img src="boxcn..." />
</metadata>
```
metadata 只用于让现有服务端链路生成 `FileMetaMap`。如果使用 `--assets assets.json` 传入预上传 tokenCLI 也会按同样规则替换和注入。
`assets.json` 格式:
```json
{
"@./image.png": "boxcn...",
"./other.png": "boxcn..."
}
```

View File

@@ -0,0 +1,103 @@
# SVGlide 视觉 Recipe
这份文档是 `slides +create-svg` 的短版可执行 recipe 指南。
它把研究目录提炼成生成阶段可放入 agent 上下文的规则。
更完整的研究资料仍保留在 CLI skill 外:
`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/visual_recipe_catalog.md`.
## 边界
- `visual_recipe` 定义页面结构,以及这一页为什么值得用 SVG。
- `style_preset` 定义视觉语言、配色、纹理、密度和 motif。
- `renderer_id` 定义具体几何渲染器。
不要用 `style_preset` 替代 `visual_recipe`。不要在 `slide_plan.json`
里发明新的 recipe id。
## 硬默认值
- 画布:`width="960" height="540" viewBox="0 0 960 540"`
- 安全区:关键文本、标签、图表、卡片、节点和图例保持在
`x=48..912` and `y=40..500`.
- 网格:使用稳定的 12 栏或 8px 步进布局,避免临时手调坐标。
- 文本:中文正文每行控制在约 28 个字;英文正文每行约 62 个字符。
- 装饰:装饰线、水印、纹理和背景几何不能抢夺标题/焦点内容的注意力,也不能贴住它们。
- Deck 多样性8 页以上 SVG deck 应至少使用 5 种 visual recipe family。
## Plan 字段
写 SVG 前,每个 SVG 页面 plan 都必须包含这些字段:
```json
{
"visual_recipe": "path_flow",
"visual_intent": "show a staged route from current state to target state",
"visual_focal_point": "curved route spine with the final target node",
"visual_signature": "curved route path plus stage annotations",
"svg_effects": ["path", "connector_flow", "typography"],
"required_primitives": ["path", "annotation"],
"svg_primitives": ["path", "annotation", "typography"],
"xml_like_risk": "without the route geometry this becomes ordinary bullets",
"content_density_contract": "flow >= 4 stages",
"risk_flags": [],
"source_policy": "do not invent unsupported numbers"
}
```
## Recipe Selection Matrix
`slide_plan.json` 中使用这些 CLI 支持的 underscore id。
| 用户意图 | `visual_recipe` | SVG source 中必须体现 |
|---|---|---|
| 封面、章节开场、hero 观点页 | `hero_typography` | 大字、几何承载体、清晰焦点对象 |
| 战略框架、强几何版式 | `geometric_composition` | 非卡片式几何、`path` 或异形区域 |
| 路线图、旅程、流程、路径 | `path_flow` | 显式 path/line 主线、箭头或阶段标记 |
| KPI、战报、数据复盘 | `infographic_scorecard` | 大数字加微图表或仪表几何 |
| 能力地图、模块总览 | `icon_capability_map` | 风格统一的 SVG-safe 图标和标注区域 |
| 层次、氛围、概念强调 | `gradient_depth` | 渐变或分层半透明几何,并保证文字可读 |
| 产品/成果/图片叙事 | `mask_clip_showcase` | 图片区域加安全的 overlay/crop 模拟 |
| 技术系统、网格、编码质感 | `technical_texture` | 重复 line/dot/rect、网格、扫描线或图解纹理 |
| 闭环、飞轮、反馈系统 | `metaphor_loop` | 闭合路径或循环流程,并带输入/输出标签 |
| 诊断、callout、焦点标注 | `spotlight_annotation` | 高亮区域、callout 线、标注目标 |
| Dashboard、控制台、监控界面 | `fake_ui_dashboard` | UI frame、状态栏、指标、微图表/日志行 |
| 品牌或系列身份页 | `brand_system` | 稳定标题系统、motif、配色和重复身份元素 |
## 安全 Effects
优先使用可以由 SVGlide-safe primitives 表达的效果:
- `path`:曲线、波形、路线、自定义形状。
- `gradient`:背景层次和重点强调;关键文字必须有稳定承载底。
- `texture`:重复的 `line``circle``rect`;不要只依赖 `<pattern>`
- `connector_flow`:显式 line/path 加箭头三角或圆点。
- `chart_geometry`:柱、点、线、仪表、坐标轴和标签。
- `grid_geometry`:矩阵、表格式视觉摘要、结构化对齐网格。
- `watermark_text`:低对比大字,不能影响阅读。
- `image_overlay`:真实图片加显式半透明 shape 覆盖层。
- `spotlight`:分层半透明形状,不依赖复杂 filter 光效。
## 高风险 Effects
这些效果只有在 `risk_flags` / `recipe_fallback` 中声明了安全改写或
fallback 时,才允许出现在 visual planning 中:
- `filter`
- `mask_clip`
- `pattern`
- `symbol`
- `stroke_dasharray`
- `image_opacity`
关键视觉在调用 `slides +create-svg` 前,应改写成显式 shape、line、dot、
overlay或预合成图片。
## 反退化规则
- 如果页面主要只是 `rect + foreignObject`,还不足以证明值得走 SVGlide
除非它同时具备真实 SVG-native 结构path、chart geometry、icon system、
texture、spotlight、dashboard frame、connector flow 或 image overlay。
- 第一眼看到的对象应该和该页 `visual_focal_point` 一致。
- 相似页面可以共享 `style_preset`,但不能只替换文案和背景色,布局骨架完全不变。
- 研究笔记里的 dotted recipe 名称,例如 `cover.hero`,不是有效运行时 id。
写入 plan 前必须映射到上面的 underscore id。

View File

@@ -44,6 +44,56 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
## Automated SVGlide Plan And SVG Preflight
`slides +create-svg` 前,必须先运行 SVG plan/source preflight
```bash
python3 skills/lark-slides/scripts/svg_preflight.py \
--plan .lark-slides/plan/<deck-id>/slide_plan.json \
--input .lark-slides/plan/<deck-id>/pages/page-001.svg
```
通过标准:
- `summary.error_count == 0`,任何 error 都必须先修复再调用 live API。
- `style_preset` 必须存在于 `references/style-presets.json`
- `style_selection_reason` 必须说明为什么这个 preset 适合当前 deck。
- `style_system` 必须包含 palette、typography、background strategy 和 motif。
- 每页必须包含 `visual_recipe``visual_signature``svg_effects``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy`
- declared `svg_effects``required_primitives` 必须能在对应 SVG source 中命中。
- 可见 slide 文本不得泄漏 preset 名称、source token、prompt、tool name 或本地文件路径。
常见 code 的处理方向:
| code | 含义 | 处理方式 |
|------|------|----------|
| `plan_style_preset_unknown` | plan 引用了不存在的 35 preset | 从 `style-presets.json` 选择有效 `style_id` |
| `plan_missing_visual_signature` | 页面没有声明 SVG 视觉记忆点 | 写清这页相对普通 PPT/XML 模板的独特视觉结构 |
| `plan_missing_svg_effects` | 页面没有声明 SVG 表达能力 | 声明真实会绘制的 `path``connector_flow``gradient``texture``chart_geometry` 等 |
| `plan_svg_effect_not_found` | plan 声明的 effect 没在 SVG source 中出现 | 修改 SVG source或删除不真实的 effect 声明 |
| `plan_style_preset_visible_leak` | 可见文本泄漏 preset 名/source token | 仅在 plan metadata 中保留 preset 信息,画面只写用户主题内容 |
## SVGlide Aesthetic Preview Review
`svg_preflight.py` 通过后,走 `slides +create-svg` 前还必须做本地预览审查。读取 [svg-aesthetic-review.md](svg-aesthetic-review.md),检查 rendered preview而不是只看 plan 字段或静态 XML。
通过标准:
- 所有页面都检查过,不只检查封面。
- 无标题、正文、badge、装饰线、图片框、图表标签的明显重叠或裁切。
- root 和主要内容遵循 `960 x 540` 画布和 safe area。
- 每页有清晰 `visual_focal_point`,视觉焦点对应 `visual_signature`
- 页面不是普通卡片/bullet 页伪装成 SVG应能看出 path、texture、chart geometry、connector flow、image overlay、icon system、dashboard frame 或其他 SVG-native 结构。
- 多页没有重复出现同一个布局错误;如果有,必须修生成规则并重新生成相关页面。
- 用户可见交付 deck 的审美目标默认不低于 `75/100`;低于 `65/100` 应重新生成或显式降级为草稿。
- 验证记录包含 `preview_path``visual_score``threshold``issue_ids``action``action=create_live` 才能继续调用 live API`action=repair_and_rerun` 必须先修 source SVG / plan 并重新跑 preflight。
这一步和 preflight 分工如下:
- `svg_preflight.py`: 负责协议、plan、枚举、必填字段、bbox、primitive 命中和确定性错误。
- `svg-aesthetic-review.md`: 负责截图/预览视角的层级、节奏、压迫感、重复问题、可读性和 SVG 视觉优势。
## Page Count And Structure
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。

View File

@@ -20,6 +20,16 @@
- Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change.
- Treat text fit as a layout constraint, not a cleanup step. If a text box is too small for the intended line count, shorten the text, split it, or allocate more space before creating XML.
## Title Zone Guardrails
The title zone is the most common place for subtle overlap. Treat badges, decorative rules, headlines, and the first content row as one unit.
- If a page uses a chapter badge, status pill, or small label above the headline, the headline text top must be at least `8` px below the badge bottom; prefer `12` px when the headline is bold or larger than `28` px.
- If a decorative horizontal line, accent rule, or divider sits above a headline, the line bottom must be at least `16` px above the headline text top; prefer `20-28` px when the headline is larger than `48` px.
- When a headline is moved down to create breathing room, move the subtitle, column headers, and main content start down together. Do not fix one collision by creating a new one below.
- Do not place large headlines directly under a top border or accent stripe. The decoration should frame the title, not press on it.
- Check the same page family across the whole deck. If one section/title page has a badge-headline collision, scan all pages with the same badge pattern before accepting the deck.
## Background And Motif Consistency
Decks can vary page backgrounds, but variation must be intentional and legible:
@@ -47,6 +57,7 @@ Use these as conservative minimums on a 960 x 540 canvas. Increase height when u
Additional rules:
- Do not put long Chinese sentences or long English phrases into `height=18` or `height=22` boxes. Those heights are for short labels only.
- Text must fit both its own text box and its nearest visible container. A card, pill, footer bar, or table band should provide enough width and height for the visible wording with padding; do not rely on clipping, browser overflow, or SVG default wrapping to hide mistakes.
- Footer/source text should usually be one short line. If it needs more, make it a real caption block above the footer area.
- Bottom conclusion bars should be at least `40` px tall for one emphasized line and at least `54` px tall for two lines.
- Diagram labels should be short enough to fit the shape. Prefer two short lines over one cramped long line.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -74,8 +74,9 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
if statusResult.ExitCode == 0 {
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
if !strings.Contains(statusResult.Stderr, `"type": "duplicate_remote_path"`) {
t.Fatalf("+status stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
if !strings.Contains(statusResult.Stderr, `"type": "validation"`) ||
!strings.Contains(statusResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+status stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
@@ -91,8 +92,9 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
if pullFailResult.ExitCode == 0 {
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if !strings.Contains(pullFailResult.Stderr, `"type": "duplicate_remote_path"`) {
t.Fatalf("+pull stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
if !strings.Contains(pullFailResult.Stderr, `"type": "validation"`) ||
!strings.Contains(pullFailResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+pull stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)

View File

@@ -1,17 +1,25 @@
# Slides CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Covered: 1
- Denominator: 4 leaf commands
- Covered: 2
- Coverage: 50.0%
## Summary
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
- TestSlidesCreateSVGDryRunRequestShape: locks the `slides +create-svg --dry-run` request chain and recursive SVGlide validation, including `g` containers, geometry-required leaves, `px` geometry, nested defs/filter, shadow style preservation, and `foreignObject` XHTML `<br />`.
- TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute: proves CLI blocks leaf shapes that would otherwise reach the server as `shape missing required attribute`.
- TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry: proves CLI blocks non-absolute geometry before issuing API calls.
- TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand: proves CLI blocks unsupported path commands before issuing API calls.
- TestSlidesCreateSVGWorkflowAsUser: opt-in live workflow for `slides +create-svg` (`LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1`); creates a two-page SVG deck as user, asserts returned page count and IDs, then reads the presentation back.
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
- Blocked area: `slides +replace-slide` has focused unit coverage but no E2E workflow yet.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
| ✓ | slides +create-svg | shortcut | slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRequestShape; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingChildRole; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand; slides_create_svg_live_test.go::TestSlidesCreateSVGWorkflowAsUser | repeated `--file`; `--title`; `--dry-run` | dry-run proves existing `/slide` route, `slide.content`, recursive SVGlide validation, server-known hard failures, numeric geometry gates, and path command gates before API call; live is opt-in and depends on the target server lane containing the updated SVGlide parser |
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |
| ✕ | slides +replace-slide | shortcut | | none | unit-covered shortcut; E2E workflow still pending |

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func setSlidesDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func TestSlidesCreateSVGDryRunRequestShape(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><style>.primary{fill:#123456;stroke:#654321;stroke-width:2px;filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2))}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><g transform="translate(20 30)"><rect slide:role="shape" class="primary" x="0" y="0" width="320px" height="180px" filter="url(#shadow)"/></g></svg>`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><div xmlns="http://www.w3.org/1999/xhtml">two<br />lines</div></foreignObject></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "Dry SVG",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<rect slide:role="shape"`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<g transform="translate(20 30)">`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<style>.primary`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<feDropShadow`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `slide:shape-type="text"`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `<br />`, "stdout:\n%s", out)
}
func TestSlidesCreateSVGDryRunRejectsMissingChildRole(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect x="80" y="80" width="320" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect> must include slide:role="shape" or slide:role="image"`)
}
func TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect slide:role="shape"> missing required attribute "width"`)
}
func TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="50%" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `attribute "width" must be a number or px length`)
}
func TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `unsupported path command or character "A"`)
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSlidesCreateSVGWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
if os.Getenv("LARKSUITE_CLI_RUN_SVGLIDE_LIVE") != "1" {
t.Skip("set LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1 to run the live SVGlide service contract test")
}
clie2e.SkipWithoutUserToken(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><g fill="#E8EEF8" transform="translate(80 80)"><rect slide:role="shape" x="0" y="0" width="360" height="180"/></g></svg>`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="120" y="120" width="420" height="100"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide live E2E</p></foreignObject></svg>`), 0o644))
parentT := t
title := "svglide-e2e-" + clie2e.GenerateSuffix()
var presentationID string
t.Run("create SVG deck as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", title,
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
if result.ExitCode != 0 {
if created := extractSVGlideFailurePresentationID(result.Stderr); created != "" {
presentationID = created
registerSlidesCleanup(parentT, presentationID)
}
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
require.Equal(t, title, gjson.Get(result.Stdout, "data.title").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.slides_added").Int(), "stdout:\n%s", result.Stdout)
require.Len(t, gjson.Get(result.Stdout, "data.slide_ids").Array(), 2, "stdout:\n%s", result.Stdout)
registerSlidesCleanup(parentT, presentationID)
})
t.Run("read back SVG-created presentation as user", func(t *testing.T) {
require.NotEmpty(t, presentationID, "presentation should be created before readback")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
DefaultAs: "user",
Params: map[string]any{"revision_id": -1},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.Equal(t, presentationID, gjson.Get(result.Stdout, "data.xml_presentation.presentation_id").String(), "stdout:\n%s", result.Stdout)
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
require.Contains(t, content, "<title>"+title+"</title>", "stdout:\n%s", result.Stdout)
})
}
func registerSlidesCleanup(t *testing.T, presentationID string) {
t.Helper()
t.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", presentationID,
"--type", "slides",
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, "delete presentation "+presentationID, deleteResult, deleteErr)
})
}
func extractSVGlideFailurePresentationID(stderr string) string {
const marker = "presentation "
idx := strings.Index(stderr, marker)
if idx < 0 {
return ""
}
rest := stderr[idx+len(marker):]
end := strings.IndexByte(rest, ' ')
if end < 0 {
return ""
}
return strings.Trim(rest[:end], ".,;:)")
}