plotlink-ows 1.0.33 → 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.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +8 -1
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +203 -22
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +951 -78
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,385 @@
1
+ /** Cover image constraints enforced by the plotlink backend. */
2
+ export const COVER_MAX_BYTES = 1024 * 1024;
3
+ export const COVER_ALLOWED_TYPES = ["image/webp", "image/jpeg"] as const;
4
+
5
+ /**
6
+ * Writer-facing cover requirements, surfaced in the cartoon cover step (#337):
7
+ * the enforced format/size plus the recommended portrait shape and a reminder to
8
+ * use clean cover art (AI-generated lettering often renders as unreadable text).
9
+ */
10
+ export const COVER_GUIDANCE =
11
+ "Cover: WebP or JPEG, max 1MB, 600×900 portrait recommended. Use clean cover art — avoid unreadable AI text or broken lettering.";
12
+
13
+ export type CoverReadinessState = "none" | "selected" | "invalid" | "attached";
14
+
15
+ export interface CoverReadiness {
16
+ state: CoverReadinessState;
17
+ /** Short writer-facing status line. */
18
+ label: string;
19
+ /** Visual tone hint for the badge. */
20
+ tone: "muted" | "accent" | "error" | "success";
21
+ }
22
+
23
+ /**
24
+ * Resolve the cartoon cover readiness shown next to publish (#337) so a writer
25
+ * always sees whether a cover is missing, queued, invalid, or attached before
26
+ * the story goes out. Precedence: an already-attached storyline cover wins;
27
+ * then an invalid selection (so the error is never hidden by a stale pick);
28
+ * then a valid local cover queued for upload; otherwise none yet.
29
+ */
30
+ export function cartoonCoverReadiness(input: {
31
+ /** A valid local cover file is queued (will upload at publish). */
32
+ hasSelectedCover: boolean;
33
+ /** The latest selection/detection was rejected (wrong type / too large). */
34
+ invalid: boolean;
35
+ /** A cover is already attached on the published storyline. */
36
+ attached: boolean;
37
+ }): CoverReadiness {
38
+ if (input.attached) {
39
+ return { state: "attached", label: "Cover attached to your story.", tone: "success" };
40
+ }
41
+ if (input.invalid) {
42
+ return { state: "invalid", label: "Cover file can't be used — must be WebP or JPEG, max 1MB.", tone: "error" };
43
+ }
44
+ if (input.hasSelectedCover) {
45
+ return { state: "selected", label: "Cover selected — it will be uploaded when you publish.", tone: "accent" };
46
+ }
47
+ return { state: "none", label: "No cover yet — add one before publishing (recommended).", tone: "muted" };
48
+ }
49
+
50
+ /**
51
+ * Validate a chosen story cover against the constraints the plotlink backend
52
+ * enforces (WebP/JPEG, ≤1MB) so the writer gets immediate feedback at selection
53
+ * rather than a late error at save. Pure — takes only size/type — and shared by
54
+ * fiction and cartoon (the cover route is content-type agnostic). The 600x900
55
+ * portrait guidance is a recommendation and is not enforced here. Returns a
56
+ * user-facing error string, or null when the file is acceptable.
57
+ */
58
+ export function validateCoverImage(file: { size: number; type: string }): string | null {
59
+ if (file.size > COVER_MAX_BYTES) return "Image exceeds 1MB limit";
60
+ if (!(COVER_ALLOWED_TYPES as readonly string[]).includes(file.type)) {
61
+ return "Only WebP and JPEG images are accepted";
62
+ }
63
+ return null;
64
+ }
65
+
66
+ type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
67
+
68
+ /**
69
+ * Attach a pre-publish cover to a freshly-created storyline. The on-chain
70
+ * `createStoryline` flow can't carry a cover CID, so after a genesis publishes
71
+ * we upload the selected cover (byte-validated server-side, #281) and set it via
72
+ * the existing `update-storyline` endpoint — the same two-step the published
73
+ * Edit Story panel uses. Best-effort: a failed upload OR a failed
74
+ * update-storyline returns null, so the storyline still stands and the writer
75
+ * can set a cover later via Edit Story. Returns the cover CID only when the
76
+ * cover was actually attached (both steps succeeded), else null.
77
+ */
78
+ export async function attachCoverToStoryline(
79
+ authFetch: AuthFetch,
80
+ storylineId: number,
81
+ coverFile: File,
82
+ ): Promise<string | null> {
83
+ const fd = new FormData();
84
+ fd.append("file", coverFile);
85
+ const upRes = await authFetch("/api/publish/upload-cover", { method: "POST", body: fd });
86
+ if (!upRes.ok) return null;
87
+ const { cid } = (await upRes.json()) as { cid?: string };
88
+ if (!cid) return null;
89
+ const updRes = await authFetch("/api/publish/update-storyline", {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ storylineId, coverCid: cid }),
93
+ });
94
+ // The cover is only attached if update-storyline also succeeds; a non-ok
95
+ // response means the cover was uploaded but never set on the storyline.
96
+ if (!updRes.ok) return null;
97
+ return cid;
98
+ }
99
+
100
+ /** The first markdown H1 (`# Title`) in `content`, trimmed; null when none. */
101
+ export function extractH1Title(content: string): string | null {
102
+ const m = content.match(/^#\s+(.+)$/m);
103
+ const t = m ? m[1].trim() : "";
104
+ return t ? t : null;
105
+ }
106
+
107
+ /**
108
+ * Prettify a story folder slug into a human title:
109
+ * "swipe-right-refund-later" → "Swipe Right Refund Later". Used only as the
110
+ * last-resort genesis title so a storyline never publishes as the bare
111
+ * "genesis" filename.
112
+ */
113
+ export function prettifyStorySlug(slug: string): string {
114
+ return slug
115
+ .replace(/[-_]+/g, " ")
116
+ .replace(/\s+/g, " ")
117
+ .trim()
118
+ .split(" ")
119
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
120
+ .join(" ");
121
+ }
122
+
123
+ /**
124
+ * Whether a resolved publish title is still a raw internal filename label
125
+ * ("genesis"/"Genesis" for genesis.md, "plot-NN" for a plot) rather than a
126
+ * reader-facing title (#358). The publish panel blocks on this so a cartoon
127
+ * story can't ship raw labels (which are immutable once on-chain). Compared
128
+ * case-insensitively and trimmed.
129
+ */
130
+ export function isRawFilenameTitle(title: string, fileName: string): boolean {
131
+ const t = (title ?? "").trim().toLowerCase();
132
+ if (!t) return true;
133
+ if (fileName === "genesis.md") return t === "genesis";
134
+ const m = fileName.match(/^(plot-\d+)\.md$/);
135
+ if (m) return t === m[1].toLowerCase() || /^plot-\d+$/.test(t);
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Friendly episode title from a plot filename (#347): "plot-01.md" → "Episode
141
+ * 01" (numbering preserved, padded to ≥2 digits). Returns null for a non-plot
142
+ * filename. Used as the last-resort cartoon episode title so an episode never
143
+ * publishes as the raw "plot-NN" filename.
144
+ */
145
+ export function episodeTitleFromPlotFile(fileName: string): string | null {
146
+ const m = fileName.match(/^plot-(\d+)\.md$/);
147
+ if (!m) return null;
148
+ const n = m[1];
149
+ return `Episode ${n.length < 2 ? n.padStart(2, "0") : n}`;
150
+ }
151
+
152
+ /**
153
+ * Whether a cartoon episode title is just a GENERIC number label rather than a
154
+ * reader-facing title (#368): "Episode 01", "Episode 1", "Ep. 01", "Chapter 01",
155
+ * "Plot 01", "plot-01", or a bare number. These pass #365's "has a title" check
156
+ * (they're a real H1 / cut-plan title) but are still placeholders that don't meet
157
+ * webtoon metadata quality, so they must not publish.
158
+ *
159
+ * A title that pairs a number with actual title text — "Episode 01 — The Couple
160
+ * Coupon" — is NOT generic (the regex anchors `$` right after the number, so any
161
+ * trailing title text fails the match). Compared trimmed / case-insensitive.
162
+ */
163
+ export function isGenericEpisodeTitle(title: string): boolean {
164
+ const t = (title ?? "").trim();
165
+ if (!t) return true;
166
+ // A generic label word (episode/ep/chapter/ch/part/pt/plot) + a number, with
167
+ // nothing meaningful after the number.
168
+ if (/^(?:episode|ep|chapter|ch|part|pt|plot)\.?\s*[-–—:#]?\s*\d+$/i.test(t)) return true;
169
+ // A bare number ("01", "1"), or a raw filename-style "plot-01"/"plot_1".
170
+ if (/^\d+$/.test(t)) return true;
171
+ if (/^plot[-_\s]?\d+$/i.test(t)) return true;
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Whether a cartoon plot has an EXPLICIT reader-facing episode title (#365,
177
+ * tightened by #368): a real `# Title` H1 in the plot markdown, or a non-empty
178
+ * cut-plan title — that is NOT a generic "Episode NN"/"Chapter NN"/"plot-NN"
179
+ * placeholder.
180
+ *
181
+ * #347/#358 stopped raw `plot-NN` titles from publishing by falling back to a
182
+ * friendly "Episode NN"; #365 made that fallback diagnostic-only. #368 closes the
183
+ * remaining gap: a real H1 or cut-plan title that is itself only a generic number
184
+ * label still doesn't satisfy publish-quality webtoon metadata, so it is rejected
185
+ * here too. Independent of the #358 raw-filename block, which is kept.
186
+ *
187
+ * The check must mirror `derivePublishTitle`'s SOURCE PRECEDENCE so the gate
188
+ * judges exactly what will publish: for a plot, the H1 wins when present, so a
189
+ * generic H1 blocks even if the cut-plan title is real (otherwise we'd pass on
190
+ * the cut title but publish the generic H1). Only when there is no H1 does the
191
+ * cut-plan title decide.
192
+ */
193
+ export function hasExplicitEpisodeTitle(opts: { fileContent: string; episodeTitle?: string | null }): boolean {
194
+ const h1 = extractH1Title(opts.fileContent);
195
+ if (h1) return !isGenericEpisodeTitle(h1);
196
+ const cut = opts.episodeTitle?.trim() || null;
197
+ return !!cut && !isGenericEpisodeTitle(cut);
198
+ }
199
+
200
+ /**
201
+ * Resolve the title used when publishing a story file to PlotLink (#331, #347).
202
+ *
203
+ * The storyline title is set once, at genesis publish, and is immutable
204
+ * on-chain — so a headingless `genesis.md` must NOT fall back to the bare
205
+ * "genesis" filename. For `genesis.md` the title resolves:
206
+ * 1. an explicit `# Title` H1 inside genesis.md, then
207
+ * 2. the `# Title` H1 from the story's structure.md, then
208
+ * 3. a prettified story folder slug — never raw "genesis".
209
+ *
210
+ * For a plot file:
211
+ * 1. an explicit `# Title` H1 in plot-NN.md, then
212
+ * 2. for CARTOON content (its publish markdown is image-only by design, so it
213
+ * usually has no H1): the cut plan's episode title, else a friendly
214
+ * "Episode NN" — NEVER the raw "plot-NN" filename (#347).
215
+ * 3. for fiction: the prior H1-or-filename behavior, unchanged.
216
+ *
217
+ * A plot's title does not change the storyline title on-chain (createStoryline
218
+ * set it; chainPlot uses this for the chapter). Result is capped at 60 chars.
219
+ */
220
+ export function derivePublishTitle(opts: {
221
+ fileName: string;
222
+ fileContent: string;
223
+ storySlug: string;
224
+ structureContent?: string | null;
225
+ contentType?: string;
226
+ /** Episode title from plot-NN.cuts.json, if any (cartoon). */
227
+ episodeTitle?: string | null;
228
+ }): string {
229
+ const { fileName, fileContent, storySlug, structureContent, contentType, episodeTitle } = opts;
230
+ const ownH1 = extractH1Title(fileContent);
231
+
232
+ if (fileName === "genesis.md") {
233
+ const structureH1 = structureContent ? extractH1Title(structureContent) : null;
234
+ return (ownH1 ?? structureH1 ?? prettifyStorySlug(storySlug)).slice(0, 60);
235
+ }
236
+
237
+ // Plot file.
238
+ if (ownH1) return ownH1.slice(0, 60);
239
+ if (contentType === "cartoon") {
240
+ const fromCuts = episodeTitle?.trim();
241
+ const friendly = episodeTitleFromPlotFile(fileName);
242
+ return ((fromCuts || friendly) ?? fileName.replace(/\.md$/, "")).slice(0, 60);
243
+ }
244
+ return fileName.replace(/\.md$/, "").slice(0, 60);
245
+ }
246
+
247
+ /** Minimal publish-status record shape needed to reason about plot duplicates. */
248
+ export interface PlotPublishRecord {
249
+ status?: "published" | "published-not-indexed" | "pending" | "draft";
250
+ storylineId?: number;
251
+ plotIndex?: number;
252
+ txHash?: string;
253
+ }
254
+
255
+ /**
256
+ * Whether a plot file already has a successful on-chain `chainPlot` recorded
257
+ * (#332). A minted chapter records its txHash + storyline + plotIndex; editing
258
+ * the file later resets `status` to "pending" but KEEPS those fields, so the
259
+ * presence of a txHash and a real plotIndex (>0) is the reliable signal that a
260
+ * chapter for this file already exists on PlotLink — republishing would mint a
261
+ * permanent duplicate chapter.
262
+ */
263
+ export function hasPriorOnChainPlot(record: PlotPublishRecord | null | undefined): boolean {
264
+ return !!record?.txHash && record?.plotIndex != null && record.plotIndex > 0;
265
+ }
266
+
267
+ /**
268
+ * Whether a fresh `chainPlot` mint for this plot file must be BLOCKED to avoid a
269
+ * duplicate chapter (#332). Blocks whenever the file already has an on-chain
270
+ * chapter, EXCEPT the `published-not-indexed` state: there the on-chain tx
271
+ * exists but indexing failed, so the recovery flow (Retry Index, or an
272
+ * explicitly-confirmed Retry Publish in the UI) is intentional and handled
273
+ * separately. A first-time publish (no prior txHash) is never blocked, so
274
+ * existing fiction/cartoon first-publish behavior is unchanged.
275
+ */
276
+ export function shouldBlockDuplicatePlotPublish(record: PlotPublishRecord | null | undefined): boolean {
277
+ return hasPriorOnChainPlot(record) && record?.status !== "published-not-indexed";
278
+ }
279
+
280
+ export function getContentTypeForPublish(
281
+ storyContentTypes: Record<string, string>,
282
+ storyName: string,
283
+ storylineId: number | undefined,
284
+ ): string | undefined {
285
+ if (storyContentTypes[storyName] === "cartoon" && !storylineId) {
286
+ return "cartoon";
287
+ }
288
+ return undefined;
289
+ }
290
+
291
+ /**
292
+ * Resolve the effective content type for the currently-selected story, falling
293
+ * back to the pending `_new_*` draft map before persistence.
294
+ *
295
+ * A freshly-created cartoon draft has no `.story.json` yet, so it is absent from
296
+ * the persisted `storyContentTypes` state; its type lives only in the in-memory
297
+ * pending map (`contentTypeMap`) until the rename/persist completes. Preview and
298
+ * terminal-launch gating must both see "cartoon" immediately — otherwise a new
299
+ * cartoon draft's terminal could launch before Codex readiness gating applies.
300
+ *
301
+ * Order: persisted state → pending draft map → "fiction" default. Returns
302
+ * undefined only when no story is selected.
303
+ */
304
+ /**
305
+ * Pure predicate: does a story need the explicit legacy-cartoon provider repair?
306
+ *
307
+ * True ONLY when ALL of:
308
+ * - the resolved content type is "cartoon", AND
309
+ * - no provider is recorded on the story (legacy `.story.json` with no
310
+ * `agentProvider`; absent ⇒ would default to Claude at launch), AND
311
+ * - it is a real, persisted story (NOT a `_new_*` draft — new drafts already
312
+ * force codex at creation, #254).
313
+ *
314
+ * Fiction, a cartoon that already has a provider, or a `_new_*` draft ⇒ false.
315
+ * This is read-only detection: it never writes or migrates anything.
316
+ */
317
+ export function needsLegacyProviderRepair(
318
+ contentType: "fiction" | "cartoon" | undefined,
319
+ agentProvider: "claude" | "codex" | undefined,
320
+ storyName: string | null,
321
+ ): boolean {
322
+ if (contentType !== "cartoon") return false;
323
+ if (agentProvider) return false;
324
+ if (!storyName || storyName.startsWith("_new_")) return false;
325
+ return true;
326
+ }
327
+
328
+ export function resolveSelectedContentType(
329
+ selectedStory: string | null,
330
+ storyContentTypes: Record<string, "fiction" | "cartoon">,
331
+ pendingContentTypes: Map<string, "fiction" | "cartoon">,
332
+ ): "fiction" | "cartoon" | undefined {
333
+ if (!selectedStory) return undefined;
334
+ return (
335
+ storyContentTypes[selectedStory] ||
336
+ pendingContentTypes.get(selectedStory) ||
337
+ "fiction"
338
+ );
339
+ }
340
+
341
+ /** Shape of the `/api/publish/preflight` response we consume in the UI (#375). */
342
+ export interface PublishPreflight {
343
+ ready?: boolean;
344
+ error?: string | null;
345
+ ethBalance?: string;
346
+ requiredBalance?: string;
347
+ creationFee?: string;
348
+ hasEnoughEth?: boolean;
349
+ address?: string;
350
+ }
351
+
352
+ /** Format a wei amount (decimal string) as ETH with 6 dp; null if unparseable. */
353
+ function weiToEth(wei: string | undefined): string | null {
354
+ if (!wei) return null;
355
+ const n = Number(wei);
356
+ if (!Number.isFinite(n)) return null;
357
+ return (n / 1e18).toFixed(6);
358
+ }
359
+
360
+ /**
361
+ * Whether a publish preflight result must block opening the publish stream (#375).
362
+ * A wallet that cannot cover at least the creation fee — or any other not-ready
363
+ * preflight state — should stop the publish action before `/api/publish/file`
364
+ * rather than proceed into "Broadcasting…" and silently fail.
365
+ */
366
+ export function isPreflightBlocked(pre: PublishPreflight | null | undefined): boolean {
367
+ return !!pre && pre.ready === false;
368
+ }
369
+
370
+ /**
371
+ * Build a durable, writer-facing block message for a not-ready publish preflight
372
+ * (#375). Prefers an explicit insufficient-balance message with the exact
373
+ * required vs. current ETH; otherwise surfaces preflight's own error.
374
+ */
375
+ export function formatPreflightBlock(pre: PublishPreflight): string {
376
+ const need = weiToEth(pre.requiredBalance) ?? weiToEth(pre.creationFee);
377
+ const have = weiToEth(pre.ethBalance);
378
+ if (pre.hasEnoughEth === false && need && have) {
379
+ return (
380
+ `Insufficient ETH: need at least ${need} ETH to publish; current balance is ${have} ETH.` +
381
+ (pre.address ? ` Top up the OWS wallet (${pre.address}) and try again.` : " Top up the OWS wallet and try again.")
382
+ );
383
+ }
384
+ return pre.error || "Publish preflight failed — the OWS wallet isn't ready to publish.";
385
+ }
@@ -0,0 +1,130 @@
1
+ // Retry/backoff for rate-limited cartoon cut image uploads (#288).
2
+ //
3
+ // The PlotLink upload endpoint rate-limits to 5 uploads/minute. A normal webtoon
4
+ // episode commonly has more than five cuts, so the batch "Upload & Generate" flow
5
+ // would otherwise fail mid-batch on the 6th+ cut with a "Rate limit exceeded"
6
+ // response. These helpers retry a single upload with backoff while it is
7
+ // rate-limited, while leaving genuine (non-rate-limit) failures to fail fast.
8
+
9
+ // PlotLink allows 5 uploads/minute, so ~12s spacing clears the window for the
10
+ // next cut. Backoff grows from here and is capped so a stuck batch still ends.
11
+ export const RATE_LIMIT_BASE_DELAY_MS = 12_000;
12
+ export const RATE_LIMIT_MAX_RETRIES = 5;
13
+ const MAX_BACKOFF_MS = 60_000;
14
+
15
+ // PlotLink's documented limit: 5 uploads per rolling 60s window. The proactive
16
+ // throttle below paces the batch to stay under this, so a 7–10 cut episode never
17
+ // blows the budget in a tight loop and then thrashes on reactive backoff (#413).
18
+ export const RATE_LIMIT_WINDOW_MS = 60_000;
19
+ export const RATE_LIMIT_BURST = 5;
20
+
21
+ /**
22
+ * A rate-limit response. The OWS route currently forwards PlotLink's rate-limit
23
+ * as a 500 carrying the upstream message ("Rate limit exceeded. Max 5 uploads
24
+ * per minute."), so we detect by status 429 OR a rate-limit message — either is
25
+ * treated as retryable.
26
+ */
27
+ export function isRateLimitError(status: number, errorMessage?: string | null): boolean {
28
+ if (status === 429) return true;
29
+ return !!errorMessage && /rate[\s-]?limit/i.test(errorMessage);
30
+ }
31
+
32
+ /** Backoff for the Nth retry (0-based): base, 2×base, 4×base, … capped. */
33
+ export function backoffMs(retry: number, baseDelayMs = RATE_LIMIT_BASE_DELAY_MS): number {
34
+ return Math.min(baseDelayMs * 2 ** retry, MAX_BACKOFF_MS);
35
+ }
36
+
37
+ export interface AttemptResult {
38
+ ok: boolean;
39
+ status: number;
40
+ errorMessage?: string | null;
41
+ }
42
+
43
+ export interface RetryDeps {
44
+ /** Injectable for tests; defaults to a real setTimeout-based sleep. */
45
+ sleep?: (ms: number) => Promise<void>;
46
+ maxRetries?: number;
47
+ baseDelayMs?: number;
48
+ /** Called once before each backoff wait so the UI can show a waiting state. */
49
+ onWaiting?: (info: { attempt: number; maxRetries: number; waitMs: number }) => void;
50
+ }
51
+
52
+ const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
53
+
54
+ /**
55
+ * Run `attempt` and, while its result is rate-limited, wait with backoff and
56
+ * retry up to `maxRetries` times. Returns the first non-rate-limited result, or
57
+ * the last rate-limited result once retries are exhausted (so the caller still
58
+ * gets the affected status/message to report). Never retries a non-rate-limit
59
+ * failure or a success.
60
+ */
61
+ export async function withRateLimitRetry<T extends AttemptResult>(
62
+ attempt: () => Promise<T>,
63
+ deps: RetryDeps = {},
64
+ ): Promise<T> {
65
+ const sleep = deps.sleep ?? defaultSleep;
66
+ const maxRetries = deps.maxRetries ?? RATE_LIMIT_MAX_RETRIES;
67
+ const baseDelayMs = deps.baseDelayMs ?? RATE_LIMIT_BASE_DELAY_MS;
68
+
69
+ let retries = 0;
70
+ for (;;) {
71
+ const result = await attempt();
72
+ if (result.ok || !isRateLimitError(result.status, result.errorMessage)) return result;
73
+ if (retries >= maxRetries) return result;
74
+ const waitMs = backoffMs(retries, baseDelayMs);
75
+ retries += 1;
76
+ deps.onWaiting?.({ attempt: retries, maxRetries, waitMs });
77
+ await sleep(waitMs);
78
+ }
79
+ }
80
+
81
+ export interface ThrottleDeps {
82
+ /** Max uploads allowed per window (default 5 = PlotLink's per-minute limit). */
83
+ limit?: number;
84
+ /** Rolling window length in ms (default 60_000). */
85
+ windowMs?: number;
86
+ /** Injectable for tests; defaults to a real setTimeout-based sleep. */
87
+ sleep?: (ms: number) => Promise<void>;
88
+ /** Injectable clock for tests; defaults to Date.now. */
89
+ now?: () => number;
90
+ /** Called once before each proactive wait so the UI can show a waiting state. */
91
+ onWaiting?: (info: { waitMs: number }) => void;
92
+ }
93
+
94
+ /**
95
+ * Build a proactive sliding-window throttle for a batch of uploads (#413).
96
+ *
97
+ * Returns an async `throttle()` to call immediately BEFORE each upload. It records
98
+ * each call's timestamp and, once `limit` uploads have happened inside the last
99
+ * `windowMs`, sleeps until the oldest of those falls out of the window before
100
+ * letting the next through — so a 7–10 cut batch paces itself under PlotLink's
101
+ * 5/min limit instead of firing all uploads at once and then thrashing on reactive
102
+ * 429 backoff. `withRateLimitRetry` stays as the safety net for any 429 that still
103
+ * slips through (e.g. budget consumed by another client). Pure aside from the
104
+ * injected clock/sleep, so it's deterministic in tests.
105
+ */
106
+ export function createUploadThrottle(deps: ThrottleDeps = {}) {
107
+ const limit = deps.limit ?? RATE_LIMIT_BURST;
108
+ const windowMs = deps.windowMs ?? RATE_LIMIT_WINDOW_MS;
109
+ const sleep = deps.sleep ?? defaultSleep;
110
+ const now = deps.now ?? (() => Date.now());
111
+ const stamps: number[] = [];
112
+
113
+ const dropExpired = () => {
114
+ const cutoff = now() - windowMs;
115
+ while (stamps.length && stamps[0] <= cutoff) stamps.shift();
116
+ };
117
+
118
+ return async function throttle(): Promise<void> {
119
+ dropExpired();
120
+ if (stamps.length >= limit) {
121
+ const waitMs = stamps[0] + windowMs - now();
122
+ if (waitMs > 0) {
123
+ deps.onWaiting?.({ waitMs });
124
+ await sleep(waitMs);
125
+ }
126
+ dropExpired();
127
+ }
128
+ stamps.push(now());
129
+ };
130
+ }
@@ -0,0 +1,105 @@
1
+ import { isRawFilenameTitle, isGenericEpisodeTitle } from "./publish-helpers";
2
+
3
+ /**
4
+ * End-to-end public-title verification for cartoon publishes (#379).
5
+ *
6
+ * Local guards (#347/#358/#365/#368) ensure OWS *sends* a reader-facing title,
7
+ * but the real pilot (`plotlink.xyz/story/59/1` rendered `genesis` / `plot-01`)
8
+ * showed we must also prove the PUBLIC, indexed metadata is reader-facing — not
9
+ * just that local preview computed a good label. After indexing, OWS reads the
10
+ * indexed storyline detail and verifies the title here. Already-published bad
11
+ * titles are immutable, so this can only warn + keep the next publish honest.
12
+ */
13
+
14
+ /** Subset of PlotLink's `GET /api/storyline/<id>` response we read for #379. */
15
+ export interface PublicStorylineDetail {
16
+ title?: string;
17
+ name?: string;
18
+ // PlotLink may expose per-episode entries (plots/chapters) with their own
19
+ // title + index; we read whichever is present, matching by plot index.
20
+ plots?: Array<{ title?: string; name?: string; index?: number; plotIndex?: number }>;
21
+ chapters?: Array<{ title?: string; name?: string; index?: number; plotIndex?: number }>;
22
+ }
23
+
24
+ export interface PublicTitleVerdict {
25
+ /** false → the indexed public title is raw/generic (verification failed). */
26
+ ok: boolean;
27
+ /** false → the relevant public title field was absent (read inconclusive). */
28
+ checked: boolean;
29
+ /** the public title actually evaluated, when present. */
30
+ publicTitle?: string;
31
+ /** human-facing failure reason, when ok === false. */
32
+ reason?: string;
33
+ }
34
+
35
+ /** Find the public title for a plot index across whichever list PlotLink returns. */
36
+ function pickPlotTitle(detail: PublicStorylineDetail, plotIndex: number | undefined): string | undefined {
37
+ const lists = [detail.plots, detail.chapters].filter(Boolean) as NonNullable<PublicStorylineDetail["plots"]>[];
38
+ for (const list of lists) {
39
+ const byIndex =
40
+ plotIndex != null ? list.find((p) => p.plotIndex === plotIndex || p.index === plotIndex) : undefined;
41
+ let entry = byIndex;
42
+ if (!entry && list.length === 1) {
43
+ // Fall back to the lone entry ONLY when it carries no index to match on
44
+ // (or the caller gave no index) — never when a known index simply differs,
45
+ // which would verify the wrong episode.
46
+ const only = list[0];
47
+ const hasIndex = only.plotIndex != null || only.index != null;
48
+ if (!hasIndex || plotIndex == null) entry = only;
49
+ }
50
+ const t = (entry?.title ?? entry?.name)?.trim();
51
+ if (t) return t;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /**
57
+ * Verify the indexed PlotLink title for a cartoon publish is reader-facing.
58
+ * Genesis (storyline) titles must not be the raw `genesis` fallback; plot titles
59
+ * must not be `plot-NN` or a generic `Episode NN` placeholder. Returns
60
+ * `checked: false` when the relevant public title is absent, so the caller never
61
+ * false-fails an inconclusive read (e.g. a transient indexer response).
62
+ */
63
+ export function verifyPublicCartoonTitle(opts: {
64
+ fileName: string;
65
+ detail: PublicStorylineDetail | null | undefined;
66
+ plotIndex?: number;
67
+ }): PublicTitleVerdict {
68
+ const { fileName, detail, plotIndex } = opts;
69
+ if (!detail) return { ok: true, checked: false };
70
+
71
+ if (fileName === "genesis.md") {
72
+ const publicTitle = (detail.title ?? detail.name)?.trim();
73
+ if (!publicTitle) return { ok: true, checked: false };
74
+ if (isRawFilenameTitle(publicTitle, "genesis.md")) {
75
+ return {
76
+ ok: false,
77
+ checked: true,
78
+ publicTitle,
79
+ reason: `PlotLink indexed the storyline title as “${publicTitle}”, a raw filename rather than the reader-facing title.`,
80
+ };
81
+ }
82
+ return { ok: true, checked: true, publicTitle };
83
+ }
84
+
85
+ const publicTitle = pickPlotTitle(detail, plotIndex);
86
+ if (!publicTitle) return { ok: true, checked: false };
87
+ if (isRawFilenameTitle(publicTitle, fileName) || isGenericEpisodeTitle(publicTitle)) {
88
+ return {
89
+ ok: false,
90
+ checked: true,
91
+ publicTitle,
92
+ reason: `PlotLink indexed the episode title as “${publicTitle}”, a generic placeholder rather than a reader-facing episode title.`,
93
+ };
94
+ }
95
+ return { ok: true, checked: true, publicTitle };
96
+ }
97
+
98
+ /** Durable, writer-facing warning shown when public-title verification fails (#379). */
99
+ export function publicTitleWarning(verdict: PublicTitleVerdict): string {
100
+ return (
101
+ `${verdict.reason ?? "PlotLink indexed a raw/generic public title for this publish."} ` +
102
+ `Published metadata is immutable on-chain and cannot be edited — the next publish must use corrected, reader-facing metadata. ` +
103
+ `(The webtoon pilot stays blocked until a publish indexes a real public title.)`
104
+ );
105
+ }
@@ -62,6 +62,15 @@ code, pre {
62
62
  font-family: "Geist Mono", ui-monospace, monospace;
63
63
  }
64
64
 
65
+ /* Cartoon awaiting-upload pending panel — calm, info-toned (NOT red). Shown
66
+ after Generate MD lays down the skeleton but before final images are
67
+ uploaded. Mirrors the planning-callout treatment so the two pending states
68
+ feel related rather than alarming. */
69
+ .cartoon-awaiting-upload {
70
+ border-color: color-mix(in srgb, var(--accent) 30%, transparent);
71
+ background: color-mix(in srgb, var(--accent) 5%, transparent);
72
+ }
73
+
65
74
  /* Ensure dim/faint terminal text (SGR 2) stays readable on cream bg */
66
75
  .xterm .xterm-dim {
67
76
  opacity: 1 !important;