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.
- 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 +8 -1
- 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 +203 -22
- 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 +951 -78
- 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-DxATSk7X.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/app/routes/stories.ts
CHANGED
|
@@ -2,6 +2,15 @@ import { Hono } from "hono";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { STORIES_DIR } from "../lib/paths";
|
|
5
|
+
import { writeStoryInstructions } from "../lib/generate-story-instructions";
|
|
6
|
+
import { readCutsFile, writeCutsFile, validateCutsFile } from "../lib/cuts";
|
|
7
|
+
import { buildStoryProgress } from "../lib/story-progress";
|
|
8
|
+
import { deriveCartoonCoach } from "../lib/cartoon-coach";
|
|
9
|
+
import { diagnoseCutAssets, summarizeAssetDiagnostics } from "../lib/cut-asset-diagnostics";
|
|
10
|
+
import { CARTOON_BUBBLE_RENDERER_VERSION } from "../lib/overlays";
|
|
11
|
+
import { mergeCartoonMarkdown } from "../lib/cartoon-markdown";
|
|
12
|
+
import { syncCleanImages, cleanImageCandidates, sniffImageType, cleanImageBytesMatchMime, findStaleAssetPaths, clearStaleAssetPaths, type SniffedType } from "../lib/clean-image-sync";
|
|
13
|
+
import { imageAssetIssue, isValidImageAsset, pngAssetExists, CLEAN_IMAGE_VALID_EXT } from "../lib/image-asset-validate";
|
|
5
14
|
|
|
6
15
|
const stories = new Hono();
|
|
7
16
|
|
|
@@ -34,6 +43,18 @@ interface StoryInfo {
|
|
|
34
43
|
hasGenesis: boolean;
|
|
35
44
|
plotCount: number;
|
|
36
45
|
publishedCount: number;
|
|
46
|
+
contentType: "fiction" | "cartoon";
|
|
47
|
+
// Publish metadata from .story.json, surfaced so the publish controls seed
|
|
48
|
+
// from the real story values (#424). Absent ⇒ could not be determined (no
|
|
49
|
+
// .story.json value, no structure.md hint, no script detection), so the client
|
|
50
|
+
// shows an explicit "Needs metadata" state instead of a misleading default
|
|
51
|
+
// (English/Romance). `genre` is the raw stored label; the client canonicalizes.
|
|
52
|
+
language?: string;
|
|
53
|
+
genre?: string;
|
|
54
|
+
isNsfw?: boolean;
|
|
55
|
+
// Optional. Absent ⇒ no provider recorded (legacy story ⇒ defaults to Claude
|
|
56
|
+
// at launch). Surfaced read-only so the client can offer a scoped repair.
|
|
57
|
+
agentProvider?: AgentProvider;
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
function readPublishStatus(storyDir: string): Record<string, FileStatus> {
|
|
@@ -51,8 +72,73 @@ function writePublishStatus(storyDir: string, status: Record<string, FileStatus>
|
|
|
51
72
|
fs.writeFileSync(statusFile, JSON.stringify(status, null, 2) + "\n");
|
|
52
73
|
}
|
|
53
74
|
|
|
75
|
+
export type AgentProvider = "claude" | "codex";
|
|
76
|
+
|
|
77
|
+
interface StoryMeta {
|
|
78
|
+
contentType: "fiction" | "cartoon";
|
|
79
|
+
// Publish metadata authored in .story.json. Surfaced so the publish controls
|
|
80
|
+
// initialize from the story's real values instead of falling back to the
|
|
81
|
+
// first-in-list defaults (Romance / English) — see #424.
|
|
82
|
+
title?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
language?: string;
|
|
85
|
+
genre?: string;
|
|
86
|
+
isNsfw?: boolean;
|
|
87
|
+
agentMode?: "normal" | "bypass";
|
|
88
|
+
// Optional. Absent ⇒ Claude (no migration). "claude" | "codex".
|
|
89
|
+
agentProvider?: AgentProvider;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readStoryMeta(storyDir: string): StoryMeta {
|
|
93
|
+
const metaFile = path.join(storyDir, ".story.json");
|
|
94
|
+
try {
|
|
95
|
+
if (fs.existsSync(metaFile)) {
|
|
96
|
+
const raw = JSON.parse(fs.readFileSync(metaFile, "utf-8"));
|
|
97
|
+
if (raw.contentType === "fiction" || raw.contentType === "cartoon") {
|
|
98
|
+
// Accept both camelCase `isNsfw` and snake_case `is_nsfw` on read; we
|
|
99
|
+
// always persist canonical `isNsfw` (see writeStoryMeta).
|
|
100
|
+
const isNsfw = typeof raw.isNsfw === "boolean" ? raw.isNsfw
|
|
101
|
+
: typeof raw.is_nsfw === "boolean" ? raw.is_nsfw
|
|
102
|
+
: undefined;
|
|
103
|
+
return {
|
|
104
|
+
contentType: raw.contentType,
|
|
105
|
+
...(typeof raw.title === "string" ? { title: raw.title } : {}),
|
|
106
|
+
...(typeof raw.description === "string" ? { description: raw.description } : {}),
|
|
107
|
+
...(typeof raw.language === "string" ? { language: raw.language } : {}),
|
|
108
|
+
...(typeof raw.genre === "string" ? { genre: raw.genre } : {}),
|
|
109
|
+
...(isNsfw !== undefined ? { isNsfw } : {}),
|
|
110
|
+
...(raw.agentMode === "bypass" || raw.agentMode === "normal" ? { agentMode: raw.agentMode } : {}),
|
|
111
|
+
...(raw.agentProvider === "claude" || raw.agentProvider === "codex" ? { agentProvider: raw.agentProvider } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch { /* ignore */ }
|
|
116
|
+
return { contentType: "fiction" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeStoryMeta(storyDir: string, meta: StoryMeta) {
|
|
120
|
+
const metaFile = path.join(storyDir, ".story.json");
|
|
121
|
+
fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + "\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseLanguageMetadata(content: string): string | null {
|
|
125
|
+
const match = content.match(/^(?:\*\*)?Language(?:\*\*)?:\s*(?:\*\*)?([^*\n]+)/im);
|
|
126
|
+
if (match) return match[1].trim();
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function detectLanguageFromScript(text: string): string | null {
|
|
131
|
+
if (/[가-]/.test(text)) return "Korean";
|
|
132
|
+
if (/[-ゟ゠-ヿ]/.test(text)) return "Japanese";
|
|
133
|
+
if (/[一-鿿]/.test(text)) return "Chinese";
|
|
134
|
+
if (/[ऀ-ॿ]/.test(text)) return "Hindi";
|
|
135
|
+
if (/[-ۿ]/.test(text)) return "Arabic";
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
54
139
|
function scanStory(storyDir: string, name: string): StoryInfo {
|
|
55
140
|
const publishStatus = readPublishStatus(storyDir);
|
|
141
|
+
const storyMeta = readStoryMeta(storyDir);
|
|
56
142
|
const entries = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
|
|
57
143
|
|
|
58
144
|
const files: FileStatus[] = entries.map((file) => {
|
|
@@ -68,14 +154,15 @@ function scanStory(storyDir: string, name: string): StoryInfo {
|
|
|
68
154
|
const plotCount = entries.filter((f) => f.match(/^plot-\d+\.md$/)).length;
|
|
69
155
|
const publishedCount = files.filter((f) => f.status === "published" || f.status === "published-not-indexed").length;
|
|
70
156
|
|
|
71
|
-
// Extract title from structure.md or genesis.md
|
|
157
|
+
// Extract title and language hints from structure.md or genesis.md
|
|
72
158
|
let title: string | null = null;
|
|
159
|
+
let structContent: string | null = null;
|
|
73
160
|
try {
|
|
74
161
|
const structPath = path.join(storyDir, "structure.md");
|
|
75
162
|
const genesisPath = path.join(storyDir, "genesis.md");
|
|
76
163
|
if (fs.existsSync(structPath)) {
|
|
77
|
-
|
|
78
|
-
const match =
|
|
164
|
+
structContent = fs.readFileSync(structPath, "utf-8");
|
|
165
|
+
const match = structContent.match(/^#\s+(.+)$/m);
|
|
79
166
|
if (match) title = match[1];
|
|
80
167
|
} else if (fs.existsSync(genesisPath)) {
|
|
81
168
|
const content = fs.readFileSync(genesisPath, "utf-8");
|
|
@@ -84,7 +171,40 @@ function scanStory(storyDir: string, name: string): StoryInfo {
|
|
|
84
171
|
}
|
|
85
172
|
} catch { /* best effort */ }
|
|
86
173
|
|
|
87
|
-
|
|
174
|
+
// Resolve language best-effort from explicit metadata → structure.md hint →
|
|
175
|
+
// script detection. Do NOT blind-default to English (#424): when nothing
|
|
176
|
+
// determines it, leave it undefined so the client shows "Needs metadata"
|
|
177
|
+
// rather than silently publishing the wrong language.
|
|
178
|
+
let language: string | undefined = storyMeta.language;
|
|
179
|
+
if (!language) {
|
|
180
|
+
const fromMetadata = structContent ? parseLanguageMetadata(structContent) : null;
|
|
181
|
+
const fromScript = title ? detectLanguageFromScript(title) : null;
|
|
182
|
+
language = fromMetadata ?? fromScript ?? undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
name,
|
|
187
|
+
// Prefer the explicit .story.json title when present (#424); fall back to
|
|
188
|
+
// the H1 parsed from structure.md / genesis.md.
|
|
189
|
+
title: storyMeta.title ?? title,
|
|
190
|
+
files,
|
|
191
|
+
hasStructure,
|
|
192
|
+
hasGenesis,
|
|
193
|
+
plotCount,
|
|
194
|
+
publishedCount,
|
|
195
|
+
contentType: storyMeta.contentType,
|
|
196
|
+
// Surfaced from .story.json/detection so the publish controls seed real
|
|
197
|
+
// values (#424); omitted when undetermined so the client shows "Needs
|
|
198
|
+
// metadata" instead of a misleading English default.
|
|
199
|
+
...(language ? { language } : {}),
|
|
200
|
+
...(storyMeta.genre ? { genre: storyMeta.genre } : {}),
|
|
201
|
+
...(typeof storyMeta.description === "string" ? { description: storyMeta.description } : {}),
|
|
202
|
+
...(storyMeta.isNsfw !== undefined ? { isNsfw: storyMeta.isNsfw } : {}),
|
|
203
|
+
// Read-only passthrough. Absent when the story has no provider recorded
|
|
204
|
+
// (legacy), so a legacy cartoon shows no provider and the client can offer
|
|
205
|
+
// the explicit repair affordance. Never written/migrated here.
|
|
206
|
+
...(storyMeta.agentProvider ? { agentProvider: storyMeta.agentProvider } : {}),
|
|
207
|
+
};
|
|
88
208
|
}
|
|
89
209
|
|
|
90
210
|
/** GET /api/stories — list all stories */
|
|
@@ -177,6 +297,842 @@ stories.get("/:name", (c) => {
|
|
|
177
297
|
return c.json({ ...info, files: filesWithContent });
|
|
178
298
|
});
|
|
179
299
|
|
|
300
|
+
/** POST /api/stories/:name/metadata — write/update .story.json */
|
|
301
|
+
stories.post("/:name/metadata", async (c) => {
|
|
302
|
+
const name = safeName(c.req.param("name"));
|
|
303
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
304
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
305
|
+
|
|
306
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
307
|
+
return c.json({ error: "Story not found" }, 404);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const body = await c.req.json<{ contentType?: string; language?: string; agentMode?: string; agentProvider?: string }>();
|
|
311
|
+
if (body.contentType !== "fiction" && body.contentType !== "cartoon") {
|
|
312
|
+
return c.json({ error: "contentType must be 'fiction' or 'cartoon'" }, 400);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const existing = readStoryMeta(storyDir);
|
|
316
|
+
const meta: StoryMeta = {
|
|
317
|
+
...existing,
|
|
318
|
+
contentType: body.contentType,
|
|
319
|
+
...(typeof body.language === "string" ? { language: body.language } : {}),
|
|
320
|
+
...(body.agentMode === "bypass" || body.agentMode === "normal" ? { agentMode: body.agentMode } : {}),
|
|
321
|
+
...(body.agentProvider === "claude" || body.agentProvider === "codex" ? { agentProvider: body.agentProvider } : {}),
|
|
322
|
+
};
|
|
323
|
+
writeStoryMeta(storyDir, meta);
|
|
324
|
+
// Provider-aware so a legacy-cartoon repair (agentProvider → codex) rewrites
|
|
325
|
+
// CLAUDE.md with the Codex file-creation contract; absent ⇒ Claude/manual.
|
|
326
|
+
writeStoryInstructions(storyDir, meta.contentType, meta.agentProvider);
|
|
327
|
+
|
|
328
|
+
return c.json({ ok: true });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* POST /api/stories/:name/publish-metadata — persist publish controls back to
|
|
333
|
+
* .story.json (#424).
|
|
334
|
+
*
|
|
335
|
+
* Lets a writer's genre/language/is-NSFW selections in the publish panel stick
|
|
336
|
+
* across refresh, keeping the controls in sync with story metadata. Unlike the
|
|
337
|
+
* /metadata route this does NOT change contentType or rewrite CLAUDE.md — it
|
|
338
|
+
* only updates publish fields, so fiction/agent behavior is untouched. Each
|
|
339
|
+
* field is optional; omitted fields are left as-is (so a single control edit
|
|
340
|
+
* never clobbers the others).
|
|
341
|
+
*/
|
|
342
|
+
stories.post("/:name/publish-metadata", async (c) => {
|
|
343
|
+
const name = safeName(c.req.param("name"));
|
|
344
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
345
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
346
|
+
|
|
347
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
348
|
+
return c.json({ error: "Story not found" }, 404);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const body = await c.req.json<{ title?: string; description?: string; language?: string; genre?: string; isNsfw?: boolean }>();
|
|
352
|
+
|
|
353
|
+
const existing = readStoryMeta(storyDir);
|
|
354
|
+
const meta: StoryMeta = {
|
|
355
|
+
...existing,
|
|
356
|
+
...(typeof body.title === "string" ? { title: body.title } : {}),
|
|
357
|
+
...(typeof body.description === "string" ? { description: body.description } : {}),
|
|
358
|
+
...(typeof body.language === "string" ? { language: body.language } : {}),
|
|
359
|
+
...(typeof body.genre === "string" ? { genre: body.genre } : {}),
|
|
360
|
+
...(typeof body.isNsfw === "boolean" ? { isNsfw: body.isNsfw } : {}),
|
|
361
|
+
};
|
|
362
|
+
writeStoryMeta(storyDir, meta);
|
|
363
|
+
|
|
364
|
+
return c.json({ ok: true });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/** Derive a filesystem-safe slug from a title (#423). Non-Latin titles (e.g.
|
|
368
|
+
* Korean) reduce to empty → fall back to "story"; the real title is kept in
|
|
369
|
+
* .story.json and is what the UI displays. */
|
|
370
|
+
function slugifyTitle(title: string): string {
|
|
371
|
+
const base = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
372
|
+
return base || "story";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* POST /api/stories/create — guided New Story setup (#423).
|
|
377
|
+
*
|
|
378
|
+
* Creates the story folder + .story.json + CLAUDE.md from the user's chosen
|
|
379
|
+
* title/metadata UP FRONT, so a normal user no longer has to prompt the agent to
|
|
380
|
+
* rename an "Untitled" project. The (possibly non-Latin) title is stored in
|
|
381
|
+
* .story.json and shown in the UI; the folder slug is an ASCII fallback. Cartoon
|
|
382
|
+
* always uses Codex (the clean-image step needs image generation).
|
|
383
|
+
*/
|
|
384
|
+
stories.post("/create", async (c) => {
|
|
385
|
+
const body = await c.req.json<{ title?: string; description?: string; language?: string; genre?: string; contentType?: string; agentMode?: string; agentProvider?: string }>();
|
|
386
|
+
const title = (body.title ?? "").trim();
|
|
387
|
+
if (!title) return c.json({ error: "Title is required" }, 400);
|
|
388
|
+
|
|
389
|
+
const contentType = body.contentType === "cartoon" ? "cartoon" : "fiction";
|
|
390
|
+
const agentProvider: AgentProvider = contentType === "cartoon" ? "codex" : (body.agentProvider === "codex" ? "codex" : "claude");
|
|
391
|
+
const agentMode = body.agentMode === "bypass" ? "bypass" : "normal";
|
|
392
|
+
|
|
393
|
+
if (!fs.existsSync(STORIES_DIR)) fs.mkdirSync(STORIES_DIR, { recursive: true });
|
|
394
|
+
const base = slugifyTitle(title);
|
|
395
|
+
let slug = base;
|
|
396
|
+
for (let i = 2; fs.existsSync(path.join(STORIES_DIR, slug)); i++) slug = `${base}-${i}`;
|
|
397
|
+
const storyDir = path.join(STORIES_DIR, slug);
|
|
398
|
+
fs.mkdirSync(storyDir, { recursive: true });
|
|
399
|
+
|
|
400
|
+
const meta: StoryMeta = {
|
|
401
|
+
contentType,
|
|
402
|
+
title,
|
|
403
|
+
...(typeof body.description === "string" && body.description.trim() ? { description: body.description.trim() } : {}),
|
|
404
|
+
...(typeof body.language === "string" && body.language ? { language: body.language } : {}),
|
|
405
|
+
...(typeof body.genre === "string" && body.genre ? { genre: body.genre } : {}),
|
|
406
|
+
agentMode,
|
|
407
|
+
agentProvider,
|
|
408
|
+
};
|
|
409
|
+
writeStoryMeta(storyDir, meta);
|
|
410
|
+
writeStoryInstructions(storyDir, contentType, agentProvider);
|
|
411
|
+
|
|
412
|
+
return c.json({ name: slug, title, contentType });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/** GET /api/stories/:name/cuts/:plotFile — read cuts.json for a plot */
|
|
416
|
+
stories.get("/:name/cuts/:plotFile", (c) => {
|
|
417
|
+
const name = safeName(c.req.param("name"));
|
|
418
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
419
|
+
if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
|
|
420
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
421
|
+
|
|
422
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
423
|
+
return c.json({ error: "Story not found" }, 404);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const cutsFile = readCutsFile(storyDir, plotFile);
|
|
428
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
429
|
+
return c.json(cutsFile);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return c.json({ error: (err as Error).message }, 400);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
/** PUT /api/stories/:name/cuts/:plotFile — update cuts.json */
|
|
436
|
+
stories.put("/:name/cuts/:plotFile", async (c) => {
|
|
437
|
+
const name = safeName(c.req.param("name"));
|
|
438
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
439
|
+
if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
|
|
440
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
441
|
+
|
|
442
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
443
|
+
return c.json({ error: "Story not found" }, 404);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const body = await c.req.json();
|
|
447
|
+
const validation = validateCutsFile(body);
|
|
448
|
+
if (!validation.valid) {
|
|
449
|
+
return c.json({ error: validation.error }, 400);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
writeCutsFile(storyDir, plotFile, body);
|
|
453
|
+
return c.json({ ok: true });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
/** POST /api/stories/:name/cuts/:plotFile/upload-clean/:cutId — upload clean image for a cut */
|
|
457
|
+
stories.post("/:name/cuts/:plotFile/upload-clean/:cutId", async (c) => {
|
|
458
|
+
const name = safeName(c.req.param("name"));
|
|
459
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
460
|
+
const cutIdStr = c.req.param("cutId");
|
|
461
|
+
if (!name || !plotFile || !cutIdStr) return c.json({ error: "Invalid path" }, 400);
|
|
462
|
+
|
|
463
|
+
const cutId = parseInt(cutIdStr, 10);
|
|
464
|
+
if (isNaN(cutId) || cutId < 1) return c.json({ error: "Invalid cut ID" }, 400);
|
|
465
|
+
|
|
466
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
467
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
468
|
+
return c.json({ error: "Story not found" }, 404);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const cutsFile = readCutsFile(storyDir, plotFile);
|
|
472
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
473
|
+
|
|
474
|
+
const cut = cutsFile.cuts.find((c) => c.id === cutId);
|
|
475
|
+
if (!cut) return c.json({ error: `Cut ${cutId} not found` }, 404);
|
|
476
|
+
|
|
477
|
+
let formData: FormData;
|
|
478
|
+
try {
|
|
479
|
+
formData = await c.req.formData();
|
|
480
|
+
} catch {
|
|
481
|
+
return c.json({ error: "No file provided" }, 400);
|
|
482
|
+
}
|
|
483
|
+
const file = formData.get("file") as File | Blob | null;
|
|
484
|
+
if (!file || (typeof file === "string")) {
|
|
485
|
+
return c.json({ error: "No file provided" }, 400);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (file.size > 1024 * 1024) {
|
|
489
|
+
return c.json({ error: "File must be under 1MB" }, 400);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const mime = file.type;
|
|
493
|
+
if (mime !== "image/webp" && mime !== "image/jpeg") {
|
|
494
|
+
return c.json({ error: "Only WebP and JPEG images are supported" }, 400);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Validate by actual file bytes, not just the (spoofable) MIME label, so a
|
|
498
|
+
// renamed text/PNG file claiming image/webp cannot be recorded as a clean
|
|
499
|
+
// image. Mirrors the magic-byte check used by sync-clean-images (#256/#266).
|
|
500
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
501
|
+
if (!cleanImageBytesMatchMime(buffer, mime)) {
|
|
502
|
+
return c.json(
|
|
503
|
+
{ error: "File content is not a valid WebP/JPEG image (bytes do not match the image type)" },
|
|
504
|
+
400,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const ext = mime === "image/webp" ? "webp" : "jpg";
|
|
509
|
+
const padded = String(cutId).padStart(2, "0");
|
|
510
|
+
const assetDir = path.join(storyDir, "assets", plotFile);
|
|
511
|
+
fs.mkdirSync(assetDir, { recursive: true });
|
|
512
|
+
|
|
513
|
+
const fileName = `cut-${padded}-clean.${ext}`;
|
|
514
|
+
const filePath = path.join(assetDir, fileName);
|
|
515
|
+
fs.writeFileSync(filePath, buffer);
|
|
516
|
+
|
|
517
|
+
const cleanImagePath = `assets/${plotFile}/cut-${padded}-clean.${ext}`;
|
|
518
|
+
cut.cleanImagePath = cleanImagePath;
|
|
519
|
+
writeCutsFile(storyDir, plotFile, cutsFile);
|
|
520
|
+
|
|
521
|
+
return c.json({ ok: true, cleanImagePath });
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
function saveExportedCut(
|
|
525
|
+
storyDir: string,
|
|
526
|
+
plotFile: string,
|
|
527
|
+
cutId: number,
|
|
528
|
+
buffer: Buffer,
|
|
529
|
+
mime: string,
|
|
530
|
+
): { finalImagePath: string } {
|
|
531
|
+
const ext = mime === "image/webp" ? "webp" : "jpg";
|
|
532
|
+
const padded = String(cutId).padStart(2, "0");
|
|
533
|
+
const assetDir = path.join(storyDir, "assets", plotFile);
|
|
534
|
+
fs.mkdirSync(assetDir, { recursive: true });
|
|
535
|
+
|
|
536
|
+
const fileName = `cut-${padded}-final.${ext}`;
|
|
537
|
+
fs.writeFileSync(path.join(assetDir, fileName), buffer);
|
|
538
|
+
|
|
539
|
+
const finalImagePath = `assets/${plotFile}/cut-${padded}-final.${ext}`;
|
|
540
|
+
|
|
541
|
+
const cutsFile = readCutsFile(storyDir, plotFile)!;
|
|
542
|
+
const cut = cutsFile.cuts.find((c) => c.id === cutId)!;
|
|
543
|
+
cut.finalImagePath = finalImagePath;
|
|
544
|
+
cut.exportedAt = new Date().toISOString();
|
|
545
|
+
// Stamp the bubble-renderer revision so a later renderer upgrade can flag this
|
|
546
|
+
// final image as stale (needing re-export) before publish (#381).
|
|
547
|
+
cut.finalRendererVersion = CARTOON_BUBBLE_RENDERER_VERSION;
|
|
548
|
+
// A NEW final image invalidates any prior upload (#381): the old PlotLink asset
|
|
549
|
+
// is the previous render (e.g. the stale separated-tail one). Clear the upload
|
|
550
|
+
// record so the cut becomes upload-eligible again — otherwise the bulk upload
|
|
551
|
+
// skips it (it filters out cuts that already have an uploadedCid) and the old
|
|
552
|
+
// image would keep publishing even after re-export.
|
|
553
|
+
cut.uploadedCid = null;
|
|
554
|
+
cut.uploadedUrl = null;
|
|
555
|
+
writeCutsFile(storyDir, plotFile, cutsFile);
|
|
556
|
+
|
|
557
|
+
return { finalImagePath };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** POST /api/stories/:name/cuts/:plotFile/export-final/:cutId — save exported final image */
|
|
561
|
+
stories.post("/:name/cuts/:plotFile/export-final/:cutId", async (c) => {
|
|
562
|
+
const name = safeName(c.req.param("name"));
|
|
563
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
564
|
+
const cutIdStr = c.req.param("cutId");
|
|
565
|
+
if (!name || !plotFile || !cutIdStr) return c.json({ error: "Invalid path" }, 400);
|
|
566
|
+
|
|
567
|
+
const cutId = parseInt(cutIdStr, 10);
|
|
568
|
+
if (isNaN(cutId) || cutId < 1) return c.json({ error: "Invalid cut ID" }, 400);
|
|
569
|
+
|
|
570
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
571
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
572
|
+
return c.json({ error: "Story not found" }, 404);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const cutsFile = readCutsFile(storyDir, plotFile);
|
|
576
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
577
|
+
|
|
578
|
+
const cut = cutsFile.cuts.find((ct) => ct.id === cutId);
|
|
579
|
+
if (!cut) return c.json({ error: `Cut ${cutId} not found` }, 404);
|
|
580
|
+
|
|
581
|
+
let formData: FormData;
|
|
582
|
+
try {
|
|
583
|
+
formData = await c.req.formData();
|
|
584
|
+
} catch {
|
|
585
|
+
return c.json({ error: "No file provided" }, 400);
|
|
586
|
+
}
|
|
587
|
+
const file = formData.get("file") as File | Blob | null;
|
|
588
|
+
if (!file || (typeof file === "string")) {
|
|
589
|
+
return c.json({ error: "No file provided" }, 400);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (file.size > 1024 * 1024) {
|
|
593
|
+
return c.json({ error: "File must be under 1MB" }, 400);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const mime = file.type;
|
|
597
|
+
if (mime !== "image/webp" && mime !== "image/jpeg") {
|
|
598
|
+
return c.json({ error: "Only WebP and JPEG images are supported" }, 400);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
602
|
+
const result = saveExportedCut(storyDir, plotFile, cutId, buffer, mime);
|
|
603
|
+
|
|
604
|
+
return c.json({ ok: true, finalImagePath: result.finalImagePath });
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
/** POST /api/stories/:name/cuts/:plotFile/set-uploaded/:cutId — record upload CID/URL for a cut */
|
|
608
|
+
stories.post("/:name/cuts/:plotFile/set-uploaded/:cutId", async (c) => {
|
|
609
|
+
const name = safeName(c.req.param("name"));
|
|
610
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
611
|
+
const cutIdStr = c.req.param("cutId");
|
|
612
|
+
if (!name || !plotFile || !cutIdStr) return c.json({ error: "Invalid path" }, 400);
|
|
613
|
+
|
|
614
|
+
const cutId = parseInt(cutIdStr, 10);
|
|
615
|
+
if (isNaN(cutId) || cutId < 1) return c.json({ error: "Invalid cut ID" }, 400);
|
|
616
|
+
|
|
617
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
618
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
619
|
+
return c.json({ error: "Story not found" }, 404);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const cutsFile = readCutsFile(storyDir, plotFile);
|
|
623
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
624
|
+
|
|
625
|
+
const cut = cutsFile.cuts.find((ct) => ct.id === cutId);
|
|
626
|
+
if (!cut) return c.json({ error: `Cut ${cutId} not found` }, 404);
|
|
627
|
+
|
|
628
|
+
const body = await c.req.json<{ cid: string; url: string }>();
|
|
629
|
+
if (!body.cid || !body.url) return c.json({ error: "cid and url required" }, 400);
|
|
630
|
+
|
|
631
|
+
cut.uploadedCid = body.cid;
|
|
632
|
+
cut.uploadedUrl = body.url;
|
|
633
|
+
writeCutsFile(storyDir, plotFile, cutsFile);
|
|
634
|
+
|
|
635
|
+
return c.json({ ok: true });
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
/** POST /api/stories/:name/cuts/:plotFile/generate-markdown — generate/update plot markdown from cuts */
|
|
639
|
+
stories.post("/:name/cuts/:plotFile/generate-markdown", async (c) => {
|
|
640
|
+
const name = safeName(c.req.param("name"));
|
|
641
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
642
|
+
if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
|
|
643
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
644
|
+
|
|
645
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
646
|
+
return c.json({ error: "Story not found" }, 404);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const cutsFile = readCutsFile(storyDir, plotFile);
|
|
650
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
651
|
+
|
|
652
|
+
const mdFile = path.join(storyDir, `${plotFile}.md`);
|
|
653
|
+
const existingMd = fs.existsSync(mdFile) ? fs.readFileSync(mdFile, "utf-8") : "";
|
|
654
|
+
|
|
655
|
+
const { markdown, warnings } = mergeCartoonMarkdown(existingMd, cutsFile.cuts);
|
|
656
|
+
fs.writeFileSync(mdFile, markdown, "utf-8");
|
|
657
|
+
|
|
658
|
+
return c.json({ ok: true, warnings });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* POST /api/stories/:name/cuts/:plotFile/sync-clean-images — detect clean image
|
|
663
|
+
* files that exist on disk and record their path on the matching cut. Only
|
|
664
|
+
* records a path when a real, valid file exists (size ≤ 1MB, allowed extension);
|
|
665
|
+
* invalid/oversized files are reported as `rejected` and never recorded.
|
|
666
|
+
*/
|
|
667
|
+
stories.post("/:name/cuts/:plotFile/sync-clean-images", (c) => {
|
|
668
|
+
const name = safeName(c.req.param("name"));
|
|
669
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
670
|
+
if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
|
|
671
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
672
|
+
|
|
673
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
674
|
+
return c.json({ error: "Story not found" }, 404);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let cutsFile;
|
|
678
|
+
try {
|
|
679
|
+
cutsFile = readCutsFile(storyDir, plotFile);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
return c.json({ error: (err as Error).message }, 400);
|
|
682
|
+
}
|
|
683
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
684
|
+
|
|
685
|
+
const rejectedMap = new Map<string, { cutId: number; reason: string }>();
|
|
686
|
+
|
|
687
|
+
// Validate a candidate relative path against the real filesystem (shared
|
|
688
|
+
// validator). Returns true ONLY when the file exists and is a valid WebP/JPEG
|
|
689
|
+
// ≤ 1MB. A present-but-invalid file (wrong extension, oversized, content
|
|
690
|
+
// mismatch) is recorded in `rejected` (deduped by path) so the writer learns
|
|
691
|
+
// why it was not recorded; a merely-absent file is silently "not found".
|
|
692
|
+
const fileExists = (relPath: string): boolean => {
|
|
693
|
+
const issue = imageAssetIssue(storyDir, relPath);
|
|
694
|
+
if (issue === null) return true;
|
|
695
|
+
if (issue === "missing") return false; // absent / non-file → not a rejection
|
|
696
|
+
const cutMatch = relPath.match(/cut-(\d+)-clean\./);
|
|
697
|
+
const cutId = cutMatch ? parseInt(cutMatch[1], 10) : 0;
|
|
698
|
+
rejectedMap.set(relPath, { cutId, reason: issue });
|
|
699
|
+
return false;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// Touch every canonical candidate so oversized/invalid files surface as
|
|
703
|
+
// rejections even when a valid one is also present for the same cut.
|
|
704
|
+
for (const cut of cutsFile.cuts) {
|
|
705
|
+
for (const rel of cleanImageCandidates(plotFile, cut.id)) {
|
|
706
|
+
fileExists(rel);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Surface any on-disk clean image with a disallowed extension (e.g. .txt) so
|
|
711
|
+
// the writer learns why it was not recorded — these never become candidates.
|
|
712
|
+
const assetDir = path.join(storyDir, "assets", plotFile);
|
|
713
|
+
if (fs.existsSync(assetDir)) {
|
|
714
|
+
const knownCutIds = new Set(cutsFile.cuts.map((cut) => cut.id));
|
|
715
|
+
for (const entry of fs.readdirSync(assetDir)) {
|
|
716
|
+
const m = entry.match(/^cut-(\d+)-clean\.([A-Za-z0-9]+)$/);
|
|
717
|
+
if (!m) continue;
|
|
718
|
+
const ext = m[2].toLowerCase();
|
|
719
|
+
const cutId = parseInt(m[1], 10);
|
|
720
|
+
if (!knownCutIds.has(cutId)) continue;
|
|
721
|
+
const rel = `assets/${plotFile}/${entry}`;
|
|
722
|
+
if (!CLEAN_IMAGE_VALID_EXT.has(ext) && !rejectedMap.has(rel)) {
|
|
723
|
+
rejectedMap.set(rel, { cutId, reason: `Unsupported extension .${ext}` });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const result = syncCleanImages(cutsFile.cuts, plotFile, fileExists);
|
|
729
|
+
const rejected = Array.from(rejectedMap.values());
|
|
730
|
+
if (result.changed) {
|
|
731
|
+
writeCutsFile(storyDir, plotFile, { ...cutsFile, cuts: result.cuts });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return c.json({ ok: true, changed: result.changed, synced: result.synced, cleared: result.cleared, rejected });
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* GET /api/stories/:name/cuts/:plotFile/detect-clean-images — dry-run detection.
|
|
739
|
+
* Reports the cut ids that have a valid local clean image on disk (exists, ≤ 1MB,
|
|
740
|
+
* magic-byte-valid, extension matches content) AND whose cut currently has
|
|
741
|
+
* `cleanImagePath === null`. This mirrors the sync route's validation but NEVER
|
|
742
|
+
* writes cuts.json — it is read-only so the client can show a per-cut affordance.
|
|
743
|
+
*/
|
|
744
|
+
stories.get("/:name/cuts/:plotFile/detect-clean-images", (c) => {
|
|
745
|
+
const name = safeName(c.req.param("name"));
|
|
746
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
747
|
+
if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
|
|
748
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
749
|
+
|
|
750
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
751
|
+
return c.json({ error: "Story not found" }, 404);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
let cutsFile;
|
|
755
|
+
try {
|
|
756
|
+
cutsFile = readCutsFile(storyDir, plotFile);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
return c.json({ error: (err as Error).message }, 400);
|
|
759
|
+
}
|
|
760
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
761
|
+
|
|
762
|
+
// Read-only validation via the shared validator (exists + allowed extension +
|
|
763
|
+
// ≤ 1MB + magic-byte content match). Never records rejections or mutates cuts.
|
|
764
|
+
const detected: number[] = [];
|
|
765
|
+
for (const cut of cutsFile.cuts) {
|
|
766
|
+
if (cut.cleanImagePath !== null) continue;
|
|
767
|
+
const hasValid = cleanImageCandidates(plotFile, cut.id).some((rel) =>
|
|
768
|
+
isValidImageAsset(storyDir, rel),
|
|
769
|
+
);
|
|
770
|
+
if (hasValid) detected.push(cut.id);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Also report recorded clean/final paths that no longer point to a valid local
|
|
774
|
+
// image (#302) so the client can show a precise per-cut error and offer the
|
|
775
|
+
// repair action instead of silently treating the cut as image-ready. Skip
|
|
776
|
+
// already-uploaded cuts: their content is on IPFS, so a missing LOCAL file is
|
|
777
|
+
// not a defect to surface.
|
|
778
|
+
const stale = findStaleAssetPaths(cutsFile.cuts, (rel) => isValidImageAsset(storyDir, rel)).filter(
|
|
779
|
+
(issue) => {
|
|
780
|
+
const cut = cutsFile!.cuts.find((ct) => ct.id === issue.cutId);
|
|
781
|
+
return !cut?.uploadedUrl;
|
|
782
|
+
},
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
return c.json({ detected, stale });
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* GET /api/stories/:name/cuts/:plotFile/asset-diagnostics — read-only per-cut
|
|
790
|
+
* asset state rescan (#427).
|
|
791
|
+
*
|
|
792
|
+
* Classifies each cut's REAL asset state against the local story folder —
|
|
793
|
+
* planned / missing / clean-ready / final-ready / uploaded — with a precise
|
|
794
|
+
* per-cut reason when a recorded path doesn't resolve (so "files exist but
|
|
795
|
+
* aren't displayed" or "a typoed path" become a clear diagnostic instead of a
|
|
796
|
+
* generic publish warning). Works for genesis.cuts.json and plot-NN.cuts.json
|
|
797
|
+
* equally. Never mutates, uploads, or publishes anything.
|
|
798
|
+
*/
|
|
799
|
+
stories.get("/:name/cuts/:plotFile/asset-diagnostics", (c) => {
|
|
800
|
+
const name = safeName(c.req.param("name"));
|
|
801
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
802
|
+
if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
|
|
803
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
804
|
+
|
|
805
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
806
|
+
return c.json({ error: "Story not found" }, 404);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
let cutsFile;
|
|
810
|
+
try {
|
|
811
|
+
cutsFile = readCutsFile(storyDir, plotFile);
|
|
812
|
+
} catch (err) {
|
|
813
|
+
return c.json({ error: (err as Error).message }, 400);
|
|
814
|
+
}
|
|
815
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
816
|
+
|
|
817
|
+
// Resolve a convertible PNG clean image for a cut (#441): the recorded clean
|
|
818
|
+
// path when it's a real PNG, else an unrecorded `cut-NN-clean.png` on disk.
|
|
819
|
+
const pngClean = (cut: { id: number; cleanImagePath: string | null }): string | null => {
|
|
820
|
+
if (cut.cleanImagePath && /\.png$/i.test(cut.cleanImagePath) && pngAssetExists(storyDir, cut.cleanImagePath)) {
|
|
821
|
+
return cut.cleanImagePath;
|
|
822
|
+
}
|
|
823
|
+
const candidate = `assets/${plotFile}/cut-${String(cut.id).padStart(2, "0")}-clean.png`;
|
|
824
|
+
return pngAssetExists(storyDir, candidate) ? candidate : null;
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const diagnostics = diagnoseCutAssets(cutsFile.cuts, (rel) => imageAssetIssue(storyDir, rel), pngClean);
|
|
828
|
+
const summary = summarizeAssetDiagnostics(diagnostics);
|
|
829
|
+
return c.json({ diagnostics, summary });
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* POST /api/stories/:name/cuts/:plotFile/repair-asset-paths — clear stale
|
|
834
|
+
* recorded asset paths (#302). Any cleanImagePath/finalImagePath that no longer
|
|
835
|
+
* points to a valid local image is reset to null; valid paths and already-
|
|
836
|
+
* uploaded cuts (uploadedCid/uploadedUrl) are preserved. This is the real repair
|
|
837
|
+
* behind the per-cut "Clear stale path" action and, unlike sync-clean-images,
|
|
838
|
+
* also repairs a stale finalImagePath.
|
|
839
|
+
*/
|
|
840
|
+
stories.post("/:name/cuts/:plotFile/repair-asset-paths", (c) => {
|
|
841
|
+
const name = safeName(c.req.param("name"));
|
|
842
|
+
const plotFile = safeName(c.req.param("plotFile"));
|
|
843
|
+
if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
|
|
844
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
845
|
+
|
|
846
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
847
|
+
return c.json({ error: "Story not found" }, 404);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
let cutsFile;
|
|
851
|
+
try {
|
|
852
|
+
cutsFile = readCutsFile(storyDir, plotFile);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
return c.json({ error: (err as Error).message }, 400);
|
|
855
|
+
}
|
|
856
|
+
if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
|
|
857
|
+
|
|
858
|
+
const result = clearStaleAssetPaths(cutsFile.cuts, (rel) => isValidImageAsset(storyDir, rel));
|
|
859
|
+
if (result.changed) {
|
|
860
|
+
writeCutsFile(storyDir, plotFile, { ...cutsFile, cuts: result.cuts });
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return c.json({ ok: true, changed: result.changed, cleared: result.cleared });
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
const COVER_MAX_BYTES = 1024 * 1024;
|
|
868
|
+
// Candidate agent-created cover files, in preference order (#296). The agent
|
|
869
|
+
// writes a single cover under assets/; we never guess plot/cut images here.
|
|
870
|
+
const COVER_CANDIDATES = [
|
|
871
|
+
{ rel: "assets/cover.webp", type: "image/webp", sniff: "webp" as const },
|
|
872
|
+
{ rel: "assets/cover.jpg", type: "image/jpeg", sniff: "jpeg" as const },
|
|
873
|
+
{ rel: "assets/cover.jpeg", type: "image/jpeg", sniff: "jpeg" as const },
|
|
874
|
+
];
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* GET /api/stories/:name/cover-asset — detect an agent-created cover image so the
|
|
878
|
+
* genesis pre-publish UI can offer it as the default cover without a manual file
|
|
879
|
+
* pick (#296). Returns the FIRST candidate that exists, with a byte-validated
|
|
880
|
+
* `valid` flag (so an oversize or spoofed cover is surfaced as a warning and not
|
|
881
|
+
* offered/uploaded). `{ found: false }` when no candidate exists.
|
|
882
|
+
*/
|
|
883
|
+
stories.get("/:name/cover-asset", (c) => {
|
|
884
|
+
const name = safeName(c.req.param("name"));
|
|
885
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
886
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
887
|
+
|
|
888
|
+
for (const cand of COVER_CANDIDATES) {
|
|
889
|
+
const full = path.join(storyDir, cand.rel);
|
|
890
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
|
|
891
|
+
|
|
892
|
+
const size = fs.statSync(full).size;
|
|
893
|
+
let sniffed: SniffedType = "unknown";
|
|
894
|
+
try {
|
|
895
|
+
const fd = fs.openSync(full, "r");
|
|
896
|
+
try {
|
|
897
|
+
const head = Buffer.alloc(16);
|
|
898
|
+
const read = fs.readSync(fd, head, 0, 16, 0);
|
|
899
|
+
sniffed = sniffImageType(head.subarray(0, read));
|
|
900
|
+
} finally {
|
|
901
|
+
fs.closeSync(fd);
|
|
902
|
+
}
|
|
903
|
+
} catch { /* treat as unreadable → invalid below */ }
|
|
904
|
+
|
|
905
|
+
if (size > COVER_MAX_BYTES) {
|
|
906
|
+
return c.json({ found: true, valid: false, path: cand.rel, type: cand.type, size, error: `${cand.rel} is ${(size / 1024).toFixed(0)}KB, exceeds the 1MB cover limit` });
|
|
907
|
+
}
|
|
908
|
+
if (sniffed !== cand.sniff) {
|
|
909
|
+
return c.json({ found: true, valid: false, path: cand.rel, type: cand.type, size, error: `${cand.rel} is not a valid ${cand.sniff.toUpperCase()} image (file contents do not match)` });
|
|
910
|
+
}
|
|
911
|
+
return c.json({ found: true, valid: true, path: cand.rel, type: cand.type, size });
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return c.json({ found: false });
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
/** Cover state for the story progress overview (#418): present / invalid / missing. */
|
|
918
|
+
function detectCoverState(storyDir: string): "missing" | "present" | "invalid" {
|
|
919
|
+
for (const cand of COVER_CANDIDATES) {
|
|
920
|
+
const full = path.join(storyDir, cand.rel);
|
|
921
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
|
|
922
|
+
const size = fs.statSync(full).size;
|
|
923
|
+
if (size > COVER_MAX_BYTES) return "invalid";
|
|
924
|
+
let sniffed: SniffedType = "unknown";
|
|
925
|
+
try {
|
|
926
|
+
const fd = fs.openSync(full, "r");
|
|
927
|
+
try {
|
|
928
|
+
const head = Buffer.alloc(16);
|
|
929
|
+
const read = fs.readSync(fd, head, 0, 16, 0);
|
|
930
|
+
sniffed = sniffImageType(head.subarray(0, read));
|
|
931
|
+
} finally {
|
|
932
|
+
fs.closeSync(fd);
|
|
933
|
+
}
|
|
934
|
+
} catch { return "invalid"; }
|
|
935
|
+
return sniffed === cand.sniff ? "present" : "invalid";
|
|
936
|
+
}
|
|
937
|
+
return "missing";
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* GET /api/stories/:name/progress — story-level production progress map (#418).
|
|
942
|
+
*
|
|
943
|
+
* Aggregates the story's metadata, setup, cover, and per-episode state into one
|
|
944
|
+
* workflow overview so a writer sees what's done and what's next without reading
|
|
945
|
+
* file names or terminal output. Cartoon episodes reuse the same readiness
|
|
946
|
+
* classifier as the per-file publish UI (so a placeholder plot reads as
|
|
947
|
+
* "placeholder", never publish-ready); fiction gets a simpler written/published
|
|
948
|
+
* view. Pure aggregation — no wallet/publish side effects.
|
|
949
|
+
*/
|
|
950
|
+
stories.get("/:name/progress", (c) => {
|
|
951
|
+
const name = safeName(c.req.param("name"));
|
|
952
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
953
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
954
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
955
|
+
return c.json({ error: "Story not found" }, 404);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const info = scanStory(storyDir, name);
|
|
959
|
+
const statusByFile = new Map(info.files.map((f) => [f.file, f.status]));
|
|
960
|
+
|
|
961
|
+
// Episodes in reader order: Genesis (Episode 1) first, then plot-NN.
|
|
962
|
+
const episodeFiles = [
|
|
963
|
+
...(info.hasGenesis ? ["genesis.md"] : []),
|
|
964
|
+
...info.files
|
|
965
|
+
.map((f) => f.file)
|
|
966
|
+
.filter((f) => /^plot-\d+\.md$/.test(f))
|
|
967
|
+
.sort((a, b) => parseInt(a.match(/\d+/)![0], 10) - parseInt(b.match(/\d+/)![0], 10)),
|
|
968
|
+
];
|
|
969
|
+
|
|
970
|
+
const episodes = episodeFiles.map((file) => {
|
|
971
|
+
const plotFile = file.replace(/\.md$/, "");
|
|
972
|
+
let markdown = "";
|
|
973
|
+
try { markdown = fs.readFileSync(path.join(storyDir, file), "utf-8"); } catch { /* missing */ }
|
|
974
|
+
let cuts = null;
|
|
975
|
+
let title: string | null = null;
|
|
976
|
+
try {
|
|
977
|
+
const cutsFile = readCutsFile(storyDir, plotFile);
|
|
978
|
+
if (cutsFile) { cuts = cutsFile.cuts; title = typeof cutsFile.title === "string" ? cutsFile.title : null; }
|
|
979
|
+
} catch { /* invalid cuts ⇒ treat as none */ }
|
|
980
|
+
return { file, status: statusByFile.get(file) ?? ("pending" as const), markdown, cuts, title };
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
const progress = buildStoryProgress({
|
|
984
|
+
name,
|
|
985
|
+
contentType: info.contentType,
|
|
986
|
+
title: info.title,
|
|
987
|
+
language: info.language ?? null,
|
|
988
|
+
genre: info.genre ?? null,
|
|
989
|
+
isNsfw: info.isNsfw ?? null,
|
|
990
|
+
hasStructure: info.hasStructure,
|
|
991
|
+
hasGenesis: info.hasGenesis,
|
|
992
|
+
cover: detectCoverState(storyDir),
|
|
993
|
+
episodes,
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// Persistent workflow coach (#429): one next action for the current state.
|
|
997
|
+
// `?focus=<file>` makes it speak about the file the writer is viewing (so a
|
|
998
|
+
// future-episode placeholder reads as "plan this first"); without it the coach
|
|
999
|
+
// tracks the story's active episode. We also scan each cartoon episode for
|
|
1000
|
+
// clean images that are on disk but not yet recorded in cuts.json, so the
|
|
1001
|
+
// clean-image stage can offer "Refresh assets" instead of "Generate" (#427).
|
|
1002
|
+
let coach = null;
|
|
1003
|
+
if (info.contentType === "cartoon") {
|
|
1004
|
+
const focusFile = c.req.query("focus") || null;
|
|
1005
|
+
const undetectedCleanByFile: Record<string, number> = {};
|
|
1006
|
+
for (const ep of episodes) {
|
|
1007
|
+
if (!ep.cuts) continue;
|
|
1008
|
+
let undetected = 0;
|
|
1009
|
+
for (const cut of ep.cuts) {
|
|
1010
|
+
if (cut.cleanImagePath !== null) continue;
|
|
1011
|
+
const plotFile = ep.file.replace(/\.md$/, "");
|
|
1012
|
+
if (cleanImageCandidates(plotFile, cut.id).some((rel) => isValidImageAsset(storyDir, rel))) undetected++;
|
|
1013
|
+
}
|
|
1014
|
+
if (undetected > 0) undetectedCleanByFile[ep.file] = undetected;
|
|
1015
|
+
}
|
|
1016
|
+
coach = deriveCartoonCoach(progress, { focusFile, undetectedCleanByFile });
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return c.json({ ...progress, coach });
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* POST /api/stories/:name/import-cover — save a browser-converted cover image as
|
|
1024
|
+
* the deterministic local asset `assets/cover.webp` (or `.jpg`) so a
|
|
1025
|
+
* Codex-generated image can become a compliant cover without agent-side shell
|
|
1026
|
+
* image tools (#301). The browser canvas path (import-image.ts) does the
|
|
1027
|
+
* PNG→WebP conversion and size compression; this route only validates and
|
|
1028
|
+
* persists. Mirrors the upload-clean byte/size checks: WebP/JPEG only, <=1MB,
|
|
1029
|
+
* magic-byte validated so a renamed/oversize file cannot land as a cover.
|
|
1030
|
+
*
|
|
1031
|
+
* To keep #296 auto-detection unambiguous (it returns the FIRST existing
|
|
1032
|
+
* candidate in webp>jpg>jpeg order), any sibling cover.* files are removed so
|
|
1033
|
+
* exactly one cover asset remains after a successful import.
|
|
1034
|
+
*/
|
|
1035
|
+
stories.post("/:name/import-cover", async (c) => {
|
|
1036
|
+
const name = safeName(c.req.param("name"));
|
|
1037
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
1038
|
+
|
|
1039
|
+
const storyDir = path.join(STORIES_DIR, name);
|
|
1040
|
+
if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
|
|
1041
|
+
return c.json({ error: "Story not found" }, 404);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
let formData: FormData;
|
|
1045
|
+
try {
|
|
1046
|
+
formData = await c.req.formData();
|
|
1047
|
+
} catch {
|
|
1048
|
+
return c.json({ error: "No file provided" }, 400);
|
|
1049
|
+
}
|
|
1050
|
+
const file = formData.get("file") as File | Blob | null;
|
|
1051
|
+
if (!file || typeof file === "string") {
|
|
1052
|
+
return c.json({ error: "No file provided" }, 400);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (file.size > 1024 * 1024) {
|
|
1056
|
+
return c.json({ error: "File must be under 1MB" }, 400);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const mime = file.type;
|
|
1060
|
+
if (mime !== "image/webp" && mime !== "image/jpeg") {
|
|
1061
|
+
return c.json({ error: "Only WebP and JPEG images are supported" }, 400);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Validate by actual bytes, not the spoofable MIME label, so an unconverted
|
|
1065
|
+
// PNG (or renamed text file) cannot be persisted as a cover. Mirrors
|
|
1066
|
+
// upload-clean (#266).
|
|
1067
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
1068
|
+
if (!cleanImageBytesMatchMime(buffer, mime)) {
|
|
1069
|
+
return c.json(
|
|
1070
|
+
{ error: "File content is not a valid WebP/JPEG image (bytes do not match the image type)" },
|
|
1071
|
+
400,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const assetDir = path.join(storyDir, "assets");
|
|
1076
|
+
fs.mkdirSync(assetDir, { recursive: true });
|
|
1077
|
+
|
|
1078
|
+
// Remove any existing cover.* so detection resolves to exactly this import.
|
|
1079
|
+
for (const cand of COVER_CANDIDATES) {
|
|
1080
|
+
const full = path.join(storyDir, cand.rel);
|
|
1081
|
+
if (fs.existsSync(full)) fs.rmSync(full, { force: true });
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const ext = mime === "image/webp" ? "webp" : "jpg";
|
|
1085
|
+
const coverPath = `assets/cover.${ext}`;
|
|
1086
|
+
fs.writeFileSync(path.join(storyDir, coverPath), buffer);
|
|
1087
|
+
|
|
1088
|
+
return c.json({ ok: true, path: coverPath, type: mime, size: buffer.length });
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
/** GET /api/stories/:name/asset/:assetPath — serve story asset file (supports nested paths) */
|
|
1092
|
+
// NOTE: uses a regex splat param (`{.+}`) rather than a bare `*` wildcard.
|
|
1093
|
+
// Hono v4 does not populate `c.req.param("*")` for a mixed named/wildcard route
|
|
1094
|
+
// like `/:name/asset/*`, so the handler always saw an empty assetPath and
|
|
1095
|
+
// returned 400 — which surfaced as "Image not available" in the UI once the
|
|
1096
|
+
// clean-image loaders actually started sending the auth header (#278). The
|
|
1097
|
+
// regex param captures the remaining path, including slashes, and is readable
|
|
1098
|
+
// back by name.
|
|
1099
|
+
stories.get("/:name/asset/:assetPath{.+}", (c) => {
|
|
1100
|
+
const name = safeName(c.req.param("name"));
|
|
1101
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
1102
|
+
|
|
1103
|
+
const assetPath = c.req.param("assetPath");
|
|
1104
|
+
if (!assetPath) return c.json({ error: "Invalid asset path" }, 400);
|
|
1105
|
+
|
|
1106
|
+
if (assetPath.includes("..") || assetPath.startsWith("/")) {
|
|
1107
|
+
return c.json({ error: "Invalid asset path" }, 400);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const fullPath = path.join(STORIES_DIR, name, "assets", assetPath);
|
|
1111
|
+
const resolved = path.resolve(fullPath);
|
|
1112
|
+
const assetsRoot = path.resolve(path.join(STORIES_DIR, name, "assets"));
|
|
1113
|
+
if (!resolved.startsWith(assetsRoot + path.sep) && resolved !== assetsRoot) {
|
|
1114
|
+
return c.json({ error: "Invalid asset path" }, 400);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (!fs.existsSync(resolved)) {
|
|
1118
|
+
return c.json({ error: "Asset not found" }, 404);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
1122
|
+
const mimeTypes: Record<string, string> = {
|
|
1123
|
+
".webp": "image/webp",
|
|
1124
|
+
".jpg": "image/jpeg",
|
|
1125
|
+
".jpeg": "image/jpeg",
|
|
1126
|
+
".png": "image/png",
|
|
1127
|
+
};
|
|
1128
|
+
const ct = mimeTypes[ext] || "application/octet-stream";
|
|
1129
|
+
|
|
1130
|
+
const data = fs.readFileSync(resolved);
|
|
1131
|
+
return new Response(data, {
|
|
1132
|
+
headers: { "Content-Type": ct, "Cache-Control": "no-cache" },
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
180
1136
|
/** GET /api/stories/:name/:file — single file content */
|
|
181
1137
|
stories.get("/:name/:file", (c) => {
|
|
182
1138
|
const name = safeName(c.req.param("name"));
|
|
@@ -290,4 +1246,4 @@ stories.post("/:name/:file/mark-not-indexed", async (c) => {
|
|
|
290
1246
|
return c.json({ ok: true });
|
|
291
1247
|
});
|
|
292
1248
|
|
|
293
|
-
export { stories as storiesRoutes, readPublishStatus, STORIES_DIR };
|
|
1249
|
+
export { stories as storiesRoutes, readPublishStatus, readStoryMeta, writeStoryMeta, saveExportedCut, STORIES_DIR };
|