plotlink-ows 1.0.33 → 1.2.95
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/active-wallet.ts +260 -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 +813 -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 +242 -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/dashboard.ts +6 -4
- package/app/routes/publish.ts +259 -45
- package/app/routes/settings.ts +92 -37
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/routes/wallet.ts +58 -30
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonNextAction.tsx +145 -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 +1337 -0
- package/app/web/components/Dashboard.tsx +15 -6
- 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 +1182 -0
- package/app/web/components/PreviewPanel.tsx +952 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +745 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +446 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +156 -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-che5mMWc.js +1 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -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/web/dist/index.html
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@400;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-Dc2TQ3Ij.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CcfChGEK.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Pre-publish summary of cartoon publish markdown (#289).
|
|
2
|
+
//
|
|
3
|
+
// The OWS publish preview must show exactly what PlotLink will render — image
|
|
4
|
+
// blocks plus ANY non-image prose actually present in the markdown — separate
|
|
5
|
+
// from the cuts.json planning inspector. This helper derives a compact summary
|
|
6
|
+
// (image count, char count, and the non-image prose that would be published) so
|
|
7
|
+
// the operator can see at a glance whether planning/placeholder text leaked into
|
|
8
|
+
// the immutable markdown (the failure mode behind #286).
|
|
9
|
+
|
|
10
|
+
export interface CartoonMarkdownSummary {
|
|
11
|
+
imageCount: number;
|
|
12
|
+
charCount: number;
|
|
13
|
+
/** Visible non-image, non-marker text that would be published as prose. */
|
|
14
|
+
nonImageProse: string;
|
|
15
|
+
/** First 200 chars of nonImageProse, for a compact pre-publish summary. */
|
|
16
|
+
nonImageProsePreview: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const IMAGE_RE = /!\[[^\]]*\]\([^)]*\)/g;
|
|
20
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
21
|
+
|
|
22
|
+
/** Length of the non-image prose excerpt surfaced in the pre-publish summary. */
|
|
23
|
+
export const PROSE_PREVIEW_LIMIT = 200;
|
|
24
|
+
|
|
25
|
+
export function summarizeCartoonMarkdown(markdown: string): CartoonMarkdownSummary {
|
|
26
|
+
const imageCount = (markdown.match(IMAGE_RE) || []).length;
|
|
27
|
+
const charCount = markdown.length;
|
|
28
|
+
|
|
29
|
+
// Strip ows:cartoon-cut markers (HTML comments) and image references; whatever
|
|
30
|
+
// remains is text that PlotLink would publish verbatim around the images.
|
|
31
|
+
const nonImageProse = markdown
|
|
32
|
+
.replace(HTML_COMMENT_RE, " ")
|
|
33
|
+
.replace(IMAGE_RE, " ")
|
|
34
|
+
.replace(/\s+/g, " ")
|
|
35
|
+
.trim();
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
imageCount,
|
|
39
|
+
charCount,
|
|
40
|
+
nonImageProse,
|
|
41
|
+
nonImageProsePreview: nonImageProse.slice(0, PROSE_PREVIEW_LIMIT),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Codex generated-image cache handoff (#403) — client side.
|
|
2
|
+
//
|
|
3
|
+
// Built-in image generation drops finished cartoon art into a hidden cache
|
|
4
|
+
// (`~/.codex/generated_images/.../ig_<hash>.png`) as a PNG, usually > 1MB. OWS
|
|
5
|
+
// only records WebP/JPEG <= 1MB clean assets, and the agent terminal is forbidden
|
|
6
|
+
// from converting the file itself (#297). Rather than make the writer hunt through
|
|
7
|
+
// that hidden folder in an OS file dialog, the OWS app lists the cache (read-only,
|
|
8
|
+
// authenticated) and lets the writer import one generated image straight into a
|
|
9
|
+
// cut — the browser converts the PNG exactly like the existing manual upload
|
|
10
|
+
// (importImageToCompliantBlob), so the upload route and its validation are
|
|
11
|
+
// unchanged.
|
|
12
|
+
//
|
|
13
|
+
// This module owns the two read-only calls to the `/api/codex` routes: list the
|
|
14
|
+
// cache, and fetch one cache image's raw bytes as a File ready for the existing
|
|
15
|
+
// import/upload pipeline. Kept thin and free of React so the fetch→File wiring is
|
|
16
|
+
// unit-testable.
|
|
17
|
+
|
|
18
|
+
/** One entry in the Codex generated-image cache, as returned by GET /api/codex/images. */
|
|
19
|
+
export interface CodexCacheImage {
|
|
20
|
+
/** Opaque, path-safe token addressing this cache file (relative to the root). */
|
|
21
|
+
token: string;
|
|
22
|
+
/** Base file name, e.g. `ig_0f26….png`, for display. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** Raw file size in bytes (pre-conversion). */
|
|
25
|
+
size: number;
|
|
26
|
+
/** Last-modified time (ms since epoch); the server lists newest first. */
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
|
|
31
|
+
|
|
32
|
+
/** Narrow an unknown listing entry to a well-formed CodexCacheImage. */
|
|
33
|
+
function isCacheImage(v: unknown): v is CodexCacheImage {
|
|
34
|
+
if (!v || typeof v !== "object") return false;
|
|
35
|
+
const o = v as Record<string, unknown>;
|
|
36
|
+
return (
|
|
37
|
+
typeof o.token === "string" &&
|
|
38
|
+
o.token.length > 0 &&
|
|
39
|
+
typeof o.name === "string" &&
|
|
40
|
+
typeof o.size === "number" &&
|
|
41
|
+
typeof o.mtimeMs === "number"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List recent Codex-generated cache images, newest first. Best-effort: a non-OK
|
|
47
|
+
* response or a malformed body yields `[]` (the cache is optional infrastructure —
|
|
48
|
+
* a writer without Codex installed simply sees no import option), and only
|
|
49
|
+
* well-formed entries are kept.
|
|
50
|
+
*/
|
|
51
|
+
export async function listCodexCacheImages(authFetch: AuthFetch): Promise<CodexCacheImage[]> {
|
|
52
|
+
let res: Response;
|
|
53
|
+
try {
|
|
54
|
+
res = await authFetch("/api/codex/images");
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) return [];
|
|
59
|
+
let data: unknown;
|
|
60
|
+
try {
|
|
61
|
+
data = await res.json();
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const images = (data as { images?: unknown })?.images;
|
|
66
|
+
if (!Array.isArray(images)) return [];
|
|
67
|
+
return images.filter(isCacheImage);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fetch one cache image's raw bytes and wrap them in a File ready for the existing
|
|
72
|
+
* per-cut import pipeline (importImageToCompliantBlob → upload-clean). The File
|
|
73
|
+
* keeps the cache entry's name and the response's real content type, so a large
|
|
74
|
+
* PNG flows through the same in-browser conversion a manually-picked PNG does.
|
|
75
|
+
* Throws a clear, user-facing error when the image can't be fetched, so callers
|
|
76
|
+
* surface the gap instead of importing nothing silently.
|
|
77
|
+
*/
|
|
78
|
+
export async function fetchCodexCacheFile(
|
|
79
|
+
authFetch: AuthFetch,
|
|
80
|
+
image: CodexCacheImage,
|
|
81
|
+
): Promise<File> {
|
|
82
|
+
let res: Response;
|
|
83
|
+
try {
|
|
84
|
+
res = await authFetch(`/api/codex/images/${encodeURIComponent(image.token)}`);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error("Could not read the generated image from the Codex cache");
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw new Error("Could not read the generated image from the Codex cache");
|
|
90
|
+
}
|
|
91
|
+
const blob = await res.blob();
|
|
92
|
+
const type = blob.type || res.headers.get("Content-Type") || "image/png";
|
|
93
|
+
return new File([blob], image.name || "codex-image.png", { type });
|
|
94
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Shared client-side image compression policy. A single source of truth for the
|
|
2
|
+
// "fit a canvas into a PlotLink-compliant (WebP/JPEG, <=1MB) image" routine so
|
|
3
|
+
// the lettering export (export-cut.ts) and the Codex-image import path
|
|
4
|
+
// (import-image.ts, #301) compress identically — both produce assets the
|
|
5
|
+
// upload/import endpoints accept without any agent-side shell image tools.
|
|
6
|
+
|
|
7
|
+
/** PlotLink hard limit for cover / clean / final cartoon assets. */
|
|
8
|
+
export const MAX_IMAGE_BYTES = 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
function canvasToBlob(
|
|
11
|
+
canvas: HTMLCanvasElement,
|
|
12
|
+
format: string,
|
|
13
|
+
quality: number,
|
|
14
|
+
): Promise<Blob> {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
canvas.toBlob(
|
|
17
|
+
(blob) => (blob ? resolve(blob) : reject(new Error(`Failed to export as ${format}`))),
|
|
18
|
+
format,
|
|
19
|
+
quality,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compress a canvas to a WebP (preferred) or JPEG Blob no larger than
|
|
26
|
+
* MAX_IMAGE_BYTES. Tries descending WebP qualities first; if the browser cannot
|
|
27
|
+
* encode WebP (toBlob silently falls back to PNG) it drops to JPEG. Throws a
|
|
28
|
+
* clear, user-facing error when even the lowest-quality JPEG exceeds the limit,
|
|
29
|
+
* so callers can surface "could not compress under 1MB" rather than silently
|
|
30
|
+
* uploading an oversize asset.
|
|
31
|
+
*/
|
|
32
|
+
export async function compressCanvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
33
|
+
const webpQualities = [0.9, 0.8, 0.7, 0.6];
|
|
34
|
+
for (const q of webpQualities) {
|
|
35
|
+
try {
|
|
36
|
+
const blob = await canvasToBlob(canvas, "image/webp", q);
|
|
37
|
+
// A browser without WebP encoding returns image/png here — stop trying
|
|
38
|
+
// WebP and fall through to the JPEG ladder rather than emit a PNG.
|
|
39
|
+
if (blob.type !== "image/webp") break;
|
|
40
|
+
if (blob.size <= MAX_IMAGE_BYTES) return blob;
|
|
41
|
+
} catch {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const jpegQualities = [0.85, 0.7, 0.5];
|
|
47
|
+
for (const q of jpegQualities) {
|
|
48
|
+
const blob = await canvasToBlob(canvas, "image/jpeg", q);
|
|
49
|
+
if (blob.size <= MAX_IMAGE_BYTES) return blob;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error("Cannot compress image under 1MB — reduce overlay count or image size");
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { compressCanvasToBlob, MAX_IMAGE_BYTES } from "./image-compress";
|
|
2
|
+
|
|
3
|
+
// OWS-owned import path for Codex-generated cartoon images (#301).
|
|
4
|
+
//
|
|
5
|
+
// Codex image generation often lands a large PNG in its local cache. OWS only
|
|
6
|
+
// accepts WebP/JPEG <=1MB for cover (assets/cover.webp) and clean
|
|
7
|
+
// (assets/plot-NN/cut-XX-clean.webp) assets, and #297 correctly forbids the
|
|
8
|
+
// agent from shelling out to ImageMagick/sharp/Playwright to convert it. This
|
|
9
|
+
// converts a selected local image entirely in the browser (canvas), so a PNG
|
|
10
|
+
// becomes a compliant asset with no agent-side image tooling.
|
|
11
|
+
|
|
12
|
+
/** Image MIME types the import/upload endpoints accept as-is. */
|
|
13
|
+
const COMPLIANT_TYPES = ["image/webp", "image/jpeg"];
|
|
14
|
+
|
|
15
|
+
/** True when a file already satisfies the PlotLink asset constraints. */
|
|
16
|
+
export function isCompliantImage(file: { type: string; size: number }): boolean {
|
|
17
|
+
return COMPLIANT_TYPES.includes(file.type) && file.size <= MAX_IMAGE_BYTES;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function decodeToCanvas(file: File): Promise<HTMLCanvasElement> {
|
|
21
|
+
if (typeof createImageBitmap !== "function") {
|
|
22
|
+
throw new Error("This browser cannot decode the image for import");
|
|
23
|
+
}
|
|
24
|
+
let bitmap: ImageBitmap;
|
|
25
|
+
try {
|
|
26
|
+
bitmap = await createImageBitmap(file);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error("Could not read the selected image — pick a PNG, WebP, or JPEG file");
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const canvas = document.createElement("canvas");
|
|
32
|
+
canvas.width = bitmap.width;
|
|
33
|
+
canvas.height = bitmap.height;
|
|
34
|
+
const ctx = canvas.getContext("2d");
|
|
35
|
+
if (!ctx) throw new Error("Could not process the image for import");
|
|
36
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
37
|
+
return canvas;
|
|
38
|
+
} finally {
|
|
39
|
+
// Release decoded pixels promptly; large PNGs are the common input here.
|
|
40
|
+
bitmap.close?.();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert a locally-selected image (PNG, WebP, JPEG, …) into a PlotLink-compliant
|
|
46
|
+
* WebP/JPEG Blob <=1MB. Already-compliant files are returned untouched (no
|
|
47
|
+
* re-encode, preserving quality and the existing manual upload behavior).
|
|
48
|
+
* Throws a clear, user-facing error when the source cannot be decoded or cannot
|
|
49
|
+
* be compressed under 1MB, so callers surface the gap instead of saving an
|
|
50
|
+
* invalid asset.
|
|
51
|
+
*/
|
|
52
|
+
export async function importImageToCompliantBlob(file: File): Promise<Blob> {
|
|
53
|
+
if (isCompliantImage(file)) return file;
|
|
54
|
+
const canvas = await decodeToCanvas(file);
|
|
55
|
+
return compressCanvasToBlob(canvas);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { MAX_IMAGE_BYTES };
|
|
@@ -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
|
+
}
|