plotlink-ows 1.0.32 → 1.2.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +10 -3
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +209 -28
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +1017 -144
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
package/README.md
CHANGED
|
@@ -256,9 +256,13 @@ PlotLink supports both human writers and AI agent writers via [ERC-8004](https:/
|
|
|
256
256
|
|
|
257
257
|
## Development
|
|
258
258
|
|
|
259
|
+
Use Node 20 with npm 10. The CI pipeline runs this combination, and newer npm
|
|
260
|
+
majors can rewrite optional peer dependency entries in `package-lock.json`.
|
|
261
|
+
|
|
259
262
|
```bash
|
|
260
263
|
git clone https://github.com/realproject7/plotlink-ows.git
|
|
261
264
|
cd plotlink-ows
|
|
265
|
+
nvm use
|
|
262
266
|
npm install
|
|
263
267
|
npm run app:dev # Start local writer app (Hono + Vite dev)
|
|
264
268
|
npm run app:build # Build frontend for production
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AgentProvider } from "../routes/stories";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure construction of a terminal agent's CLI invocation as argv.
|
|
5
|
+
*
|
|
6
|
+
* This module performs NO fs/pty/env/network access so command building is
|
|
7
|
+
* fully unit-testable. It returns `{ command, args }` (the binary + argv).
|
|
8
|
+
*
|
|
9
|
+
* Claude (KEEP BYTE-IDENTICAL with the legacy inline behavior):
|
|
10
|
+
* - fresh: `claude --session-id <newSessionId>`
|
|
11
|
+
* - resume: `claude --resume <sessionId>`
|
|
12
|
+
* - bypass: append `--dangerously-skip-permissions`
|
|
13
|
+
*
|
|
14
|
+
* Codex (net-new). Both fresh AND resume carry the story cwd (`--cd`) and the
|
|
15
|
+
* `image_generation` capability — a resumed cartoon session needs the same
|
|
16
|
+
* working directory and image-gen feature as a fresh one (see #265):
|
|
17
|
+
* - fresh: `codex --enable image_generation --cd <storyDir>`
|
|
18
|
+
* - resume: `codex resume <sessionId> --enable image_generation --cd <storyDir>`
|
|
19
|
+
* (subcommand style) when an id is stored, otherwise
|
|
20
|
+
* `codex resume --last --enable image_generation --cd <storyDir>`.
|
|
21
|
+
* NEVER `--resume <id>`.
|
|
22
|
+
* - bypass: append `--dangerously-bypass-approvals-and-sandbox`
|
|
23
|
+
*
|
|
24
|
+
* Claude-only and Codex-only flags are never mixed across providers.
|
|
25
|
+
*/
|
|
26
|
+
export type AgentMode = "normal" | "bypass";
|
|
27
|
+
|
|
28
|
+
export interface BuildAgentCommandOptions {
|
|
29
|
+
provider: AgentProvider;
|
|
30
|
+
mode: AgentMode;
|
|
31
|
+
resume: boolean;
|
|
32
|
+
/** Stored resume id (Claude UUID / Codex session id), or null. */
|
|
33
|
+
sessionId: string | null;
|
|
34
|
+
/** Freshly generated UUID used for a brand-new Claude session. */
|
|
35
|
+
newSessionId: string;
|
|
36
|
+
/** Absolute story working directory (used by Codex `--cd`). */
|
|
37
|
+
storyDir: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AgentCommand {
|
|
41
|
+
command: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildAgentCommand(opts: BuildAgentCommandOptions): AgentCommand {
|
|
46
|
+
if (opts.provider === "codex") {
|
|
47
|
+
return buildCodexCommand(opts);
|
|
48
|
+
}
|
|
49
|
+
return buildClaudeArgs(opts);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildClaudeArgs(opts: BuildAgentCommandOptions): AgentCommand {
|
|
53
|
+
const args: string[] = [];
|
|
54
|
+
// Resume only when requested AND a stored id exists; else fresh session.
|
|
55
|
+
if (opts.resume && opts.sessionId) {
|
|
56
|
+
args.push("--resume", opts.sessionId);
|
|
57
|
+
} else {
|
|
58
|
+
args.push("--session-id", opts.newSessionId);
|
|
59
|
+
}
|
|
60
|
+
if (opts.mode === "bypass") {
|
|
61
|
+
args.push("--dangerously-skip-permissions");
|
|
62
|
+
}
|
|
63
|
+
return { command: "claude", args };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildCodexCommand(opts: BuildAgentCommandOptions): AgentCommand {
|
|
67
|
+
const args: string[] = [];
|
|
68
|
+
if (opts.resume) {
|
|
69
|
+
// Codex resume is a subcommand (never `--resume <id>`). The subcommand and
|
|
70
|
+
// its target come first, then the capability/cwd flags.
|
|
71
|
+
if (opts.sessionId) {
|
|
72
|
+
args.push("resume", opts.sessionId);
|
|
73
|
+
} else {
|
|
74
|
+
args.push("resume", "--last");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Both fresh and resume need the image-generation capability and the story
|
|
78
|
+
// cwd so a resumed cartoon session lands in the right directory with image
|
|
79
|
+
// generation enabled (not just whatever global session `--last` would pick).
|
|
80
|
+
args.push("--enable", "image_generation", "--cd", opts.storyDir);
|
|
81
|
+
if (opts.mode === "bypass") {
|
|
82
|
+
args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
83
|
+
}
|
|
84
|
+
return { command: "codex", args };
|
|
85
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Agent (CLI) readiness detection.
|
|
2
|
+
//
|
|
3
|
+
// This module is pure: every shell interaction goes through an injected `run`
|
|
4
|
+
// function so it can be unit-tested without spawning processes. The route layer
|
|
5
|
+
// supplies a real `run` that shells out via the user's login shell, and stamps
|
|
6
|
+
// the `checkedAt` timestamp (kept OUT of this pure function so it stays
|
|
7
|
+
// deterministic/testable — no clocks here).
|
|
8
|
+
//
|
|
9
|
+
// Codex image-generation detection parses the structured `codex features list`
|
|
10
|
+
// output rather than guessing from generic `--help` text. See
|
|
11
|
+
// `probeAgentReadiness` for the exact parsing rules.
|
|
12
|
+
|
|
13
|
+
export type ImageGenStatus = "enabled" | "disabled" | "unknown";
|
|
14
|
+
|
|
15
|
+
// Codex auth/login hint. "ok" when `codex features list` could actually be read
|
|
16
|
+
// (so we trust the imageGeneration verdict); "unknown" when Codex is installed
|
|
17
|
+
// but its capabilities couldn't be read — commonly a logged-out / unclear-auth
|
|
18
|
+
// state. Best-effort and conservative: default "unknown", never blocks fiction.
|
|
19
|
+
export type AuthStatus = "ok" | "unknown";
|
|
20
|
+
|
|
21
|
+
export interface AgentReadiness {
|
|
22
|
+
claude: { installed: boolean };
|
|
23
|
+
codex: { installed: boolean; version: string | null; imageGeneration: ImageGenStatus; auth: AuthStatus };
|
|
24
|
+
checkedAt: number; // epoch ms — added by the route, NOT by the pure probe.
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Distinct "you may not be logged in to Codex" signal (#263): Codex is installed
|
|
29
|
+
* but `codex features list` couldn't be read, so the actionable next step is a
|
|
30
|
+
* Codex login (outside OWS), NOT enabling a feature. Pure + shared so the New
|
|
31
|
+
* Story flow, the terminal launch-blocked panel, and Settings stay consistent.
|
|
32
|
+
*/
|
|
33
|
+
export function isCodexAuthUnclear(
|
|
34
|
+
readiness: Pick<AgentReadiness, "codex"> | null | undefined,
|
|
35
|
+
): boolean {
|
|
36
|
+
return !!readiness && readiness.codex.installed && readiness.codex.auth === "unknown";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Operator-facing copy for the auth-unclear case (#263). Shared across surfaces. */
|
|
40
|
+
export const CODEX_AUTH_UNCLEAR_MESSAGE =
|
|
41
|
+
"Codex is installed but its capabilities couldn't be read — you may need to log in to Codex (resolve outside OWS), then re-check.";
|
|
42
|
+
|
|
43
|
+
/** First non-empty, trimmed line of a command's stdout (or null). */
|
|
44
|
+
function firstNonEmptyTrimmedLine(stdout: string): string | null {
|
|
45
|
+
for (const raw of stdout.split("\n")) {
|
|
46
|
+
const line = raw.trim();
|
|
47
|
+
if (line.length > 0) return line;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Determine the effective image_generation state from a single matched line of
|
|
54
|
+
* `codex features list` output.
|
|
55
|
+
*
|
|
56
|
+
* Rules:
|
|
57
|
+
* - "enabled" when the line shows a truthy state: `true`, `enabled`, `on`, or
|
|
58
|
+
* a trailing check mark (✓).
|
|
59
|
+
* - "disabled" when the line shows a falsy state: `false`, `disabled`, `off`.
|
|
60
|
+
* - "unknown" when image_generation is present but the state is unparseable.
|
|
61
|
+
*
|
|
62
|
+
* Truthy is checked before falsy is irrelevant because a single line never
|
|
63
|
+
* carries both; we test falsy first to avoid `disabled` matching a substring of
|
|
64
|
+
* something truthy (there is none, but order keeps intent explicit).
|
|
65
|
+
*/
|
|
66
|
+
function parseImageGenLine(line: string): ImageGenStatus {
|
|
67
|
+
const l = line.toLowerCase();
|
|
68
|
+
// Falsy markers.
|
|
69
|
+
if (/\b(false|disabled|off)\b/.test(l)) return "disabled";
|
|
70
|
+
// Truthy markers (word states or a trailing check mark).
|
|
71
|
+
if (/\b(true|enabled|on)\b/.test(l) || /✓/.test(line)) return "enabled";
|
|
72
|
+
return "unknown";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Probe local agent CLIs. Pure: all shelling-out is injected via `run`.
|
|
77
|
+
* Returns everything except `checkedAt` (the route stamps that with Date.now()).
|
|
78
|
+
*
|
|
79
|
+
* Checks performed:
|
|
80
|
+
* - claude.installed: `claude --version` succeeds.
|
|
81
|
+
* - codex.installed: `codex --version` succeeds.
|
|
82
|
+
* - codex.version: first non-empty line of `codex --version` stdout (or null).
|
|
83
|
+
* - codex.imageGeneration: parsed from `codex features list`:
|
|
84
|
+
* * codex not installed -> "unknown"
|
|
85
|
+
* * `codex features list` fails / empty -> "unknown"
|
|
86
|
+
* * line mentions image_generation: -> parseImageGenLine(...)
|
|
87
|
+
* * successful listing WITHOUT the line -> "disabled"
|
|
88
|
+
* (a real `features list` that omits image_generation means the feature
|
|
89
|
+
* isn't available)
|
|
90
|
+
*/
|
|
91
|
+
export async function probeAgentReadiness(
|
|
92
|
+
run: (cmd: string) => Promise<{ ok: boolean; stdout: string }>,
|
|
93
|
+
): Promise<Omit<AgentReadiness, "checkedAt">> {
|
|
94
|
+
const claudeInstalled = (await run("claude --version")).ok;
|
|
95
|
+
|
|
96
|
+
const codexVersionResult = await run("codex --version");
|
|
97
|
+
const codexInstalled = codexVersionResult.ok;
|
|
98
|
+
const codexVersion = codexInstalled
|
|
99
|
+
? firstNonEmptyTrimmedLine(codexVersionResult.stdout) || null
|
|
100
|
+
: null;
|
|
101
|
+
|
|
102
|
+
let imageGeneration: ImageGenStatus = "unknown";
|
|
103
|
+
// Conservative default: until we can actually read `codex features list`, treat
|
|
104
|
+
// auth as unclear (covers not-installed and logged-out states alike).
|
|
105
|
+
let auth: AuthStatus = "unknown";
|
|
106
|
+
|
|
107
|
+
if (codexInstalled) {
|
|
108
|
+
const features = await run("codex features list");
|
|
109
|
+
if (features.ok && features.stdout.trim().length > 0) {
|
|
110
|
+
// A readable feature listing means Codex auth/login is working.
|
|
111
|
+
auth = "ok";
|
|
112
|
+
// Accept either `image_generation` or `image-generation` naming.
|
|
113
|
+
const matchLine = features.stdout
|
|
114
|
+
.split("\n")
|
|
115
|
+
.find((line) => {
|
|
116
|
+
const l = line.toLowerCase();
|
|
117
|
+
return l.includes("image_generation") || l.includes("image-generation");
|
|
118
|
+
});
|
|
119
|
+
if (matchLine) {
|
|
120
|
+
imageGeneration = parseImageGenLine(matchLine);
|
|
121
|
+
} else {
|
|
122
|
+
// Successful listing that never mentions image_generation => not available.
|
|
123
|
+
imageGeneration = "disabled";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// else: command failed or empty -> stays "unknown".
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
claude: { installed: claudeInstalled },
|
|
131
|
+
codex: { installed: codexInstalled, version: codexVersion, imageGeneration, auth },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local SQLite schema setup WITHOUT the native Prisma schema-engine (#484).
|
|
5
|
+
*
|
|
6
|
+
* The installed `plotlink-ows` package must bring its SQLite schema up at
|
|
7
|
+
* startup, but `prisma db push` spawns a platform-specific schema-engine binary
|
|
8
|
+
* that fails to start in some packed prod-only installs (an empty
|
|
9
|
+
* "Schema engine error:" on macOS arm64). The Prisma *client* the app already
|
|
10
|
+
* uses runs on the library query engine — a different, reliably-present engine —
|
|
11
|
+
* and can execute the DDL directly via `$executeRawUnsafe`.
|
|
12
|
+
*
|
|
13
|
+
* So we ship the canonical DDL as `app/prisma/schema.sql` (generated from
|
|
14
|
+
* `schema.prisma` with `npm run prisma:sql`) and apply it through the client.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Split a committed `.sql` file into individual executable statements, dropping
|
|
19
|
+
* `-- ...` comment lines and blanks. Our DDL is a small, controlled grammar
|
|
20
|
+
* (CREATE TABLE / CREATE [UNIQUE] INDEX) with no semicolons inside values, so a
|
|
21
|
+
* `;`-split is safe here.
|
|
22
|
+
*/
|
|
23
|
+
export function parseSqlStatements(sql: string): string[] {
|
|
24
|
+
return sql
|
|
25
|
+
.split(";")
|
|
26
|
+
.map((chunk) =>
|
|
27
|
+
chunk
|
|
28
|
+
.split("\n")
|
|
29
|
+
.filter((line) => !line.trim().startsWith("--"))
|
|
30
|
+
.join("\n")
|
|
31
|
+
.trim(),
|
|
32
|
+
)
|
|
33
|
+
.filter((stmt) => stmt.length > 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rewrite a CREATE statement to be idempotent so applying the schema on an
|
|
38
|
+
* already-initialized database is a no-op (the app applies it on every startup).
|
|
39
|
+
* Only the CREATE TABLE / CREATE [UNIQUE] INDEX forms our schema emits are
|
|
40
|
+
* rewritten; anything else is returned unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export function makeIdempotent(statement: string): string {
|
|
43
|
+
return statement
|
|
44
|
+
.replace(/^CREATE TABLE\s+(?!IF NOT EXISTS)/i, "CREATE TABLE IF NOT EXISTS ")
|
|
45
|
+
.replace(
|
|
46
|
+
/^CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF NOT EXISTS)/i,
|
|
47
|
+
(_match, unique) => `CREATE ${unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS `,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read the committed schema DDL and return idempotent, ready-to-execute statements. */
|
|
52
|
+
export function loadSchemaStatements(schemaSqlPath: string): string[] {
|
|
53
|
+
const sql = fs.readFileSync(schemaSqlPath, "utf8");
|
|
54
|
+
return parseSqlStatements(sql).map(makeIdempotent);
|
|
55
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Shared text layout for cartoon lettering bubbles (#310).
|
|
2
|
+
//
|
|
3
|
+
// Both the export canvas (export-cut.ts) and the editor preview (LetteringEditor)
|
|
4
|
+
// run THIS function with a canvas `measureText`-based width measurer, so dialogue
|
|
5
|
+
// wraps by words and the font is sized to fit the bubble identically in the
|
|
6
|
+
// preview and the exported final image (WYSIWYG). Previously each drew a single
|
|
7
|
+
// maxWidth-compressed line, so long dialogue overflowed/clipped and the preview
|
|
8
|
+
// did not match the export.
|
|
9
|
+
|
|
10
|
+
export interface BubbleTextLayout {
|
|
11
|
+
/** Wrapped lines of body text (never empty; [""] for empty text). */
|
|
12
|
+
lines: string[];
|
|
13
|
+
/** Chosen body font size in the caller's pixel space. */
|
|
14
|
+
fontSize: number;
|
|
15
|
+
/** Line advance (fontSize * lineHeightFactor). */
|
|
16
|
+
lineHeight: number;
|
|
17
|
+
/** Speaker label font size, or 0 when there is no speaker. */
|
|
18
|
+
speakerFontSize: number;
|
|
19
|
+
/**
|
|
20
|
+
* True when the text did not fit even at the minimum font (the lines are a
|
|
21
|
+
* best-effort wrap that may clip/overflow the box). Drives the editor's
|
|
22
|
+
* text-overflow warning (#336). Export rendering ignores it (unchanged).
|
|
23
|
+
*/
|
|
24
|
+
overflow: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BubbleLayoutOptions {
|
|
28
|
+
/** Largest body font to try, in the caller's pixel space. */
|
|
29
|
+
maxFontSize: number;
|
|
30
|
+
/** Smallest body font (used even if text still overflows). */
|
|
31
|
+
minFontSize: number;
|
|
32
|
+
/** Fixed body font size; when present, skip auto-fit and use this size. */
|
|
33
|
+
fontSize?: number;
|
|
34
|
+
/** Line advance as a multiple of font size. Default 1.2. */
|
|
35
|
+
lineHeightFactor?: number;
|
|
36
|
+
/** Speaker-label size as a multiple of body font size. Default 0.8. */
|
|
37
|
+
speakerScale?: number;
|
|
38
|
+
/** Body text weight, for consistent bold/regular measurement and layout. */
|
|
39
|
+
fontWeight?: 400 | 700;
|
|
40
|
+
/** Horizontal padding inside the box (each side). Default 6% of width. */
|
|
41
|
+
paddingX?: number;
|
|
42
|
+
/** Vertical padding inside the box (each side). Default 8% of height. */
|
|
43
|
+
paddingY?: number;
|
|
44
|
+
/** Present a speaker label strip above the body. Default false. */
|
|
45
|
+
hasSpeaker?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Measure rendered width of `text` at `fontSize` (canvas measureText-backed). */
|
|
49
|
+
export type MeasureWidth = (text: string, fontSize: number, fontWeight?: 400 | 700) => number;
|
|
50
|
+
|
|
51
|
+
/** Greedy word-wrap of `text` to lines no wider than `maxWidth` at `fontSize`. */
|
|
52
|
+
export function wrapText(
|
|
53
|
+
measure: MeasureWidth,
|
|
54
|
+
text: string,
|
|
55
|
+
maxWidth: number,
|
|
56
|
+
fontSize: number,
|
|
57
|
+
): string[] {
|
|
58
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
59
|
+
if (words.length === 0) return [""];
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
let current = "";
|
|
62
|
+
for (const word of words) {
|
|
63
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
64
|
+
// Keep a word on the current line if it fits, or if the line is empty (a
|
|
65
|
+
// single over-long word still occupies its own line — the fit loop shrinks
|
|
66
|
+
// the font until it fits the box).
|
|
67
|
+
if (!current || measure(candidate, fontSize) <= maxWidth) {
|
|
68
|
+
current = candidate;
|
|
69
|
+
} else {
|
|
70
|
+
lines.push(current);
|
|
71
|
+
current = word;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (current) lines.push(current);
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lay out bubble text: pick the largest font (between min and max) at which the
|
|
80
|
+
* word-wrapped lines fit the box width AND total height, reserving a strip for a
|
|
81
|
+
* speaker label when present. Deterministic given the same `measure`, so the
|
|
82
|
+
* editor preview and the export canvas produce identical wrapping/sizing.
|
|
83
|
+
*/
|
|
84
|
+
export function layoutBubbleText(
|
|
85
|
+
measure: MeasureWidth,
|
|
86
|
+
text: string,
|
|
87
|
+
boxWidth: number,
|
|
88
|
+
boxHeight: number,
|
|
89
|
+
opts: BubbleLayoutOptions,
|
|
90
|
+
): BubbleTextLayout {
|
|
91
|
+
const lineHeightFactor = opts.lineHeightFactor ?? 1.2;
|
|
92
|
+
const speakerScale = opts.speakerScale ?? 0.8;
|
|
93
|
+
const padX = opts.paddingX ?? Math.max(2, boxWidth * 0.06);
|
|
94
|
+
const padY = opts.paddingY ?? Math.max(2, boxHeight * 0.08);
|
|
95
|
+
const availW = Math.max(1, boxWidth - 2 * padX);
|
|
96
|
+
const totalAvailH = Math.max(1, boxHeight - 2 * padY);
|
|
97
|
+
|
|
98
|
+
const maxFont = Math.max(opts.minFontSize, opts.maxFontSize);
|
|
99
|
+
const minFont = Math.max(1, Math.min(opts.minFontSize, maxFont));
|
|
100
|
+
|
|
101
|
+
const fit = (bodyFont: number): { lines: string[]; ok: boolean } => {
|
|
102
|
+
const speakerFont = opts.hasSpeaker ? bodyFont * speakerScale : 0;
|
|
103
|
+
const speakerStrip = opts.hasSpeaker ? speakerFont * lineHeightFactor : 0;
|
|
104
|
+
const bodyAvailH = Math.max(1, totalAvailH - speakerStrip);
|
|
105
|
+
const fontWeight = opts.fontWeight ?? 400;
|
|
106
|
+
const lines = wrapText((line, fontSize) => measure(line, fontSize, fontWeight), text, availW, bodyFont);
|
|
107
|
+
const bodyH = lines.length * bodyFont * lineHeightFactor;
|
|
108
|
+
const widthOk = lines.every((l) => measure(l, bodyFont, fontWeight) <= availW + 0.5);
|
|
109
|
+
return { lines, ok: bodyH <= bodyAvailH && widthOk };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (typeof opts.fontSize === "number" && Number.isFinite(opts.fontSize) && opts.fontSize > 0) {
|
|
113
|
+
const bodyFont = Math.max(1, opts.fontSize);
|
|
114
|
+
const { lines, ok } = fit(bodyFont);
|
|
115
|
+
return {
|
|
116
|
+
lines,
|
|
117
|
+
fontSize: bodyFont,
|
|
118
|
+
lineHeight: bodyFont * lineHeightFactor,
|
|
119
|
+
speakerFontSize: opts.hasSpeaker ? bodyFont * speakerScale : 0,
|
|
120
|
+
overflow: !ok,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Descend from max to min font (0.5px steps) and take the first that fits.
|
|
125
|
+
for (let f = maxFont; f >= minFont; f -= 0.5) {
|
|
126
|
+
const { lines, ok } = fit(f);
|
|
127
|
+
if (ok) {
|
|
128
|
+
return {
|
|
129
|
+
lines,
|
|
130
|
+
fontSize: f,
|
|
131
|
+
lineHeight: f * lineHeightFactor,
|
|
132
|
+
speakerFontSize: opts.hasSpeaker ? f * speakerScale : 0,
|
|
133
|
+
overflow: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Nothing fits even at min — best effort: wrap at min font (may overflow).
|
|
139
|
+
const lines = wrapText(measure, text, availW, minFont);
|
|
140
|
+
return {
|
|
141
|
+
lines,
|
|
142
|
+
fontSize: minFont,
|
|
143
|
+
lineHeight: minFont * lineHeightFactor,
|
|
144
|
+
speakerFontSize: opts.hasSpeaker ? minFont * speakerScale : 0,
|
|
145
|
+
overflow: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Default body min/max font sizes for a bubble, as fractions of the rendering
|
|
151
|
+
* HEIGHT so the export (natural image size) and the editor preview (displayed
|
|
152
|
+
* size) scale together — identical wrapping at both scales. `renderHeight` is
|
|
153
|
+
* the canvas/image height in the caller's pixel space.
|
|
154
|
+
*/
|
|
155
|
+
export function defaultBubbleFontRange(renderHeight: number): { minFontSize: number; maxFontSize: number } {
|
|
156
|
+
return {
|
|
157
|
+
minFontSize: Math.max(1, renderHeight * 0.022),
|
|
158
|
+
maxFontSize: Math.max(1, renderHeight * 0.05),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Persistent cartoon workflow coach (#429).
|
|
2
|
+
//
|
|
3
|
+
// The cartoon production flow is long and non-obvious — create story → bible →
|
|
4
|
+
// Genesis → plan cuts → clean images → letter → export → upload → prepare →
|
|
5
|
+
// publish → verify. Each individual screen was improved across #418–#427, but a
|
|
6
|
+
// normal writer still needs ONE persistent, front-end guide that converts the
|
|
7
|
+
// current story/episode state into a single clear next action, without reading
|
|
8
|
+
// terminal logs or technical warnings.
|
|
9
|
+
//
|
|
10
|
+
// This derives that coach PURELY from the already-built `StoryProgress` (which
|
|
11
|
+
// the route assembles from .story.json, structure.md, genesis.md, the cuts.json
|
|
12
|
+
// files, local assets, exports, uploaded URLs and publish status), plus a small
|
|
13
|
+
// per-episode disk hint (clean images present on disk but not yet recorded). It
|
|
14
|
+
// returns one stage label + one primary action, typed as either an agent
|
|
15
|
+
// copy-paste prompt or a direct in-app UI action. Fiction returns null so the
|
|
16
|
+
// fiction UX is completely untouched.
|
|
17
|
+
|
|
18
|
+
import type { StoryProgress, EpisodeProgress } from "./story-progress";
|
|
19
|
+
|
|
20
|
+
/** A direct, app-driven next step the UI can perform/route to. */
|
|
21
|
+
export type CoachUiAction =
|
|
22
|
+
| "open-cuts" // reveal the cut workspace
|
|
23
|
+
| "open-lettering" // open the cut workspace to letter / export
|
|
24
|
+
| "refresh-assets" // re-scan local clean images (#427)
|
|
25
|
+
| "upload" // upload the final images
|
|
26
|
+
| "generate-markdown" // "Prepare the episode for publish"
|
|
27
|
+
| "publish" // publish the episode to PlotLink
|
|
28
|
+
| "view-progress"; // open the story progress overview (#418)
|
|
29
|
+
|
|
30
|
+
export type CoachActionKind = "agent" | "ui";
|
|
31
|
+
|
|
32
|
+
export interface CartoonCoach {
|
|
33
|
+
/** Short current-stage label, e.g. "Clean images ready". */
|
|
34
|
+
stageLabel: string;
|
|
35
|
+
/** One primary next action in user-facing verbs, e.g. "Review cuts and start lettering". */
|
|
36
|
+
action: string;
|
|
37
|
+
actionKind: CoachActionKind;
|
|
38
|
+
/** Copy-paste agent prompt when `actionKind === "agent"`; null for UI actions. */
|
|
39
|
+
prompt: string | null;
|
|
40
|
+
/** The in-app action key when `actionKind === "ui"`; null for agent actions. */
|
|
41
|
+
uiAction: CoachUiAction | null;
|
|
42
|
+
/** Episode this action concerns (so the overview can deep-link), or null for setup-level steps. */
|
|
43
|
+
episodeFile: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CoachOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Currently-viewed file (e.g. "plot-02.md"). When it names an unfinished
|
|
49
|
+
* cartoon episode the coach speaks about THAT episode, so a future-episode
|
|
50
|
+
* placeholder reads as "Plan this episode first" instead of pointing at the
|
|
51
|
+
* story's active episode. Ignored for non-episode files (structure.md) and
|
|
52
|
+
* already-published episodes — those fall back to the story's active episode.
|
|
53
|
+
*/
|
|
54
|
+
focusFile?: string | null;
|
|
55
|
+
/**
|
|
56
|
+
* Per-episode count of clean images present on disk but NOT yet recorded in
|
|
57
|
+
* cuts.json (acceptance #2). When > 0 at the clean-image stage the coach
|
|
58
|
+
* surfaces "Refresh assets" (re-detect) instead of "Generate clean images".
|
|
59
|
+
*/
|
|
60
|
+
undetectedCleanByFile?: Record<string, number>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function agent(stageLabel: string, action: string, prompt: string, episodeFile: string | null): CartoonCoach {
|
|
64
|
+
return { stageLabel, action, actionKind: "agent", prompt, uiAction: null, episodeFile };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ui(stageLabel: string, action: string, uiAction: CoachUiAction, episodeFile: string | null): CartoonCoach {
|
|
68
|
+
return { stageLabel, action, actionKind: "ui", prompt: null, uiAction, episodeFile };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** "genesis.cuts.json" | "plot-01.cuts.json" — the cut plan a writer points the agent at. */
|
|
72
|
+
function cutsFileName(episodeFile: string): string {
|
|
73
|
+
return episodeFile === "genesis.md" ? "genesis.cuts.json" : episodeFile.replace(/\.md$/, ".cuts.json");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert the story/episode state into the single next action a cartoon writer
|
|
78
|
+
* should take. Returns null for fiction (so fiction UX is unchanged) and for a
|
|
79
|
+
* cartoon story that is already fully published with nothing queued.
|
|
80
|
+
*/
|
|
81
|
+
export function deriveCartoonCoach(progress: StoryProgress, opts: CoachOptions = {}): CartoonCoach | null {
|
|
82
|
+
if (progress.contentType !== "cartoon") return null;
|
|
83
|
+
|
|
84
|
+
// Setup gates block the whole story, so they take priority over any episode —
|
|
85
|
+
// regardless of which file is in focus. These mirror buildStoryProgress's
|
|
86
|
+
// setup ordering so the coach never disagrees with the progress overview.
|
|
87
|
+
if (!progress.setup.hasStructure) {
|
|
88
|
+
return agent(
|
|
89
|
+
"New cartoon story",
|
|
90
|
+
"Write the story bible",
|
|
91
|
+
"Let's build this cartoon. Write the story bible (structure.md) — visual style, character bible, and episode format. Don't generate images, letter, upload, or publish yet.",
|
|
92
|
+
"structure.md",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (!progress.setup.hasGenesis) {
|
|
96
|
+
return agent(
|
|
97
|
+
"Story bible ready",
|
|
98
|
+
"Write the Genesis (Episode 1) opening",
|
|
99
|
+
"Write the Genesis (Episode 1) opening for this cartoon, then plan its cuts in genesis.cuts.json. Don't generate images yet.",
|
|
100
|
+
"genesis.md",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The episode the coach speaks about: the focused file when it's an unfinished
|
|
105
|
+
// episode, otherwise the story's active (first unpublished) episode.
|
|
106
|
+
const episodes = progress.episodes;
|
|
107
|
+
const focused = opts.focusFile ? episodes.find((e) => e.file === opts.focusFile) : undefined;
|
|
108
|
+
const active = episodes.find((e) => !e.published);
|
|
109
|
+
const ep = focused && !focused.published ? focused : active;
|
|
110
|
+
|
|
111
|
+
if (!ep) {
|
|
112
|
+
// Every episode is published — nudge toward the next one rather than a wall
|
|
113
|
+
// of "all done".
|
|
114
|
+
return agent(
|
|
115
|
+
"All episodes published",
|
|
116
|
+
"Start the next episode",
|
|
117
|
+
"Plan the cuts for the next episode in a new cuts.json. Don't generate images yet.",
|
|
118
|
+
null,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return coachForEpisode(ep, opts.undetectedCleanByFile?.[ep.file] ?? 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The per-episode production pipeline, in the order a writer performs it
|
|
127
|
+
* (#429): plan cuts → clean images → letter → export → upload → prepare →
|
|
128
|
+
* publish. Each stage emits one stage label + one primary action; the
|
|
129
|
+
* pre-image steps are agent prompts, the rest are in-app UI actions.
|
|
130
|
+
*/
|
|
131
|
+
function coachForEpisode(ep: EpisodeProgress, undetectedClean: number): CartoonCoach {
|
|
132
|
+
const c = ep.cuts;
|
|
133
|
+
const label = ep.label;
|
|
134
|
+
const file = ep.file;
|
|
135
|
+
const isGenesis = ep.kind === "genesis";
|
|
136
|
+
|
|
137
|
+
// No cut plan yet — a not-started episode or a future-episode placeholder.
|
|
138
|
+
// Acceptance #3: this reads as "plan this first", never a publish warning.
|
|
139
|
+
if (!c || c.total === 0) {
|
|
140
|
+
return agent(
|
|
141
|
+
`${label} not started`,
|
|
142
|
+
isGenesis ? "Plan the Genesis cuts" : "Plan this episode first",
|
|
143
|
+
isGenesis
|
|
144
|
+
? "Plan the cuts for the Genesis (Episode 1) in genesis.cuts.json. Don't generate images, letter, upload, or publish yet."
|
|
145
|
+
: `Plan the cuts for ${label} in ${cutsFileName(file)}. Don't generate images, letter, upload, or publish yet.`,
|
|
146
|
+
file,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 1) Clean images — agent-generated. If images are already on disk but not yet
|
|
151
|
+
// recorded, the next action is a read-only re-scan instead (#427), not a
|
|
152
|
+
// redundant "generate again".
|
|
153
|
+
if (c.withClean < c.needClean) {
|
|
154
|
+
if (undetectedClean > 0) {
|
|
155
|
+
return ui(
|
|
156
|
+
"Clean images found on disk",
|
|
157
|
+
"Refresh assets to detect them",
|
|
158
|
+
"refresh-assets",
|
|
159
|
+
file,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return agent(
|
|
163
|
+
`${label} cuts planned`,
|
|
164
|
+
"Generate clean images",
|
|
165
|
+
`Generate clean images for every cut in ${cutsFileName(file)}. Don't letter, upload, or publish yet.`,
|
|
166
|
+
file,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 2) Lettering — place speech bubbles & captions in the cut workspace.
|
|
171
|
+
if (c.withText < c.needClean) {
|
|
172
|
+
return ui("Clean images ready", "Review cuts and start lettering", "open-lettering", file);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 3) Export the lettered final images.
|
|
176
|
+
if (c.exported < c.total) {
|
|
177
|
+
return ui("Lettering in progress", "Finish and export the final images", "open-lettering", file);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 4) Upload the exported final images.
|
|
181
|
+
if (c.uploaded < c.total) {
|
|
182
|
+
return ui("Final images ready", "Upload the final images", "upload", file);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 5) Every cut is uploaded — assemble the publish layout, then publish. Driven
|
|
186
|
+
// by the same readiness state the per-file publish UI uses, so the coach and
|
|
187
|
+
// the publish controls never disagree.
|
|
188
|
+
switch (ep.state) {
|
|
189
|
+
case "ready":
|
|
190
|
+
return ui("Ready to publish", `Publish ${label} to PlotLink`, "publish", file);
|
|
191
|
+
case "blocked":
|
|
192
|
+
return ui("Needs fixes before publishing", "Review and fix the publish issues", "open-cuts", file);
|
|
193
|
+
case "planning":
|
|
194
|
+
default:
|
|
195
|
+
// Images uploaded but the publish layout (cut blocks) isn't built yet.
|
|
196
|
+
return ui("Images uploaded", "Prepare the episode for publish", "generate-markdown", file);
|
|
197
|
+
}
|
|
198
|
+
}
|