@gentbajko/slopify 2.1.1 → 2.3.0
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/dist/assets/document/SOURCE.txt +5 -1
- package/dist/assets/document/sample-cover.jpg +0 -0
- package/dist/edge/http/app.js +4 -0
- package/dist/edge/http/document-themes.js +80 -0
- package/dist/edge/http/pronunciations.js +17 -0
- package/dist/edge/http/storage.js +106 -21
- package/dist/kernel/db/migrate.js +10 -1
- package/dist/kernel/db/migrations/0016-document-themes.sql +10 -0
- package/dist/kernel/db/migrations/0017-legacy-dicemaster-theme.sql +12 -0
- package/dist/kernel/db/migrations/0018-backup-imports.sql +7 -0
- package/dist/slices/admission/schema.js +15 -3
- package/dist/slices/admission/start.js +16 -1
- package/dist/slices/article/source-lines.js +20 -0
- package/dist/slices/document/legacy-dicemaster.js +118 -0
- package/dist/slices/document/library.js +77 -0
- package/dist/slices/document/model.js +40 -5
- package/dist/slices/document/sample.js +51 -0
- package/dist/slices/document/sources.js +5 -10
- package/dist/slices/document/theme-schema.js +173 -0
- package/dist/slices/document/theme.js +25 -44
- package/dist/slices/narration/shared-glossary.js +51 -0
- package/dist/slices/play-drafts/convert.js +12 -3
- package/dist/slices/play-drafts/schema.js +7 -7
- package/dist/slices/project-templates/from-project.js +9 -1
- package/dist/slices/rebuild/preview-details.js +9 -2
- package/dist/slices/rebuild/recipe-document.js +2 -3
- package/dist/slices/rebuild/recipe-text.js +9 -1
- package/dist/slices/rebuild/runtime-document.js +2 -3
- package/dist/slices/rebuild/runtime-store.js +19 -3
- package/dist/slices/storage/backup-export.js +321 -0
- package/dist/slices/storage/backup-format.js +155 -0
- package/dist/slices/storage/backup-import.js +749 -0
- package/dist/slices/storage/portable.js +27 -1
- package/dist/slices/storage/tar.js +257 -0
- package/dist/web/assets/index-Cw1mnRrx.js +168 -0
- package/dist/web/assets/index-kAKdJa-U.css +1 -0
- package/dist/web/assets/pdf-BkdJFKQ-.js +54 -0
- package/dist/web/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
- package/dist/web/assets/pdf.worker.min-zC6nfbxG.js +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B6NbhpjD.css +0 -1
- package/dist/web/assets/index-DV01Cyjx.js +0 -142
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
background.jpg
|
|
2
|
-
The parchment page texture
|
|
2
|
+
The parchment page texture any document theme can use as its background, from the lore2script2 PDF
|
|
3
3
|
generator (same owner).
|
|
4
4
|
SHA-256: 18f9701b476f299e942cbb99c05ba3267d8341df2b6136e3aebbafad83b03b31
|
|
5
5
|
|
|
6
6
|
fonts/
|
|
7
7
|
Cinzel, SIL Open Font License 1.1: see fonts/SOURCE.txt and fonts/OFL.txt.
|
|
8
|
+
|
|
9
|
+
sample-cover.jpg
|
|
10
|
+
A plain gradient made with ImageMagick for the Library theme preview, standing in for a project's
|
|
11
|
+
thumbnail. No third-party content.
|
|
Binary file
|
package/dist/edge/http/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import { actionRoutes } from "./actions.js";
|
|
|
6
6
|
import { audioPreviewRoutes } from "./audio-preview.js";
|
|
7
7
|
import { checkpointRoutes } from "./checkpoints.js";
|
|
8
8
|
import { diagnosticsRoutes } from "./diagnostics.js";
|
|
9
|
+
import { documentThemeRoutes } from "./document-themes.js";
|
|
9
10
|
import { draftRoutes } from "./drafts.js";
|
|
10
11
|
import { entryRoutes } from "./entries.js";
|
|
11
12
|
import { fileRoutes } from "./files.js";
|
|
@@ -16,6 +17,7 @@ import { problem, problemFromError, titleOf } from "./problem.js";
|
|
|
16
17
|
import { projectTemplateRoutes } from "./project-templates.js";
|
|
17
18
|
import { projectRoutes } from "./projects.js";
|
|
18
19
|
import { promptRoutes } from "./prompts.js";
|
|
20
|
+
import { pronunciationRoutes } from "./pronunciations.js";
|
|
19
21
|
import { providerRoutes } from "./providers.js";
|
|
20
22
|
import { revisionFileRoutes, revisionFolderRoutes } from "./revision-files.js";
|
|
21
23
|
import { revisionRoutes } from "./revisions.js";
|
|
@@ -58,6 +60,8 @@ function apiRoutes(deps, startedAt) {
|
|
|
58
60
|
.route("/fonts", fontsRoutes(deps))
|
|
59
61
|
.route("/prompts", promptRoutes(deps))
|
|
60
62
|
.route("/entries", entryRoutes(deps))
|
|
63
|
+
.route("/document-themes", documentThemeRoutes(deps))
|
|
64
|
+
.route("/pronunciations", pronunciationRoutes(deps))
|
|
61
65
|
.route("/telemetry", telemetryRoutes(deps))
|
|
62
66
|
.route("/usage", usageRoutes(deps))
|
|
63
67
|
.route("/settings", settingsRoutes(deps))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { createDocumentTheme, listDocumentThemes, removeDocumentTheme, updateDocumentTheme, } from "../../slices/document/library.js";
|
|
5
|
+
import { documentThemeLabels, documentThemes } from "../../slices/document/model.js";
|
|
6
|
+
import { renderSampleDocument } from "../../slices/document/sample.js";
|
|
7
|
+
import { builtInTheme, resolveTheme } from "../../slices/document/theme.js";
|
|
8
|
+
import { documentThemeSchema } from "../../slices/document/theme-schema.js";
|
|
9
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
10
|
+
const idParam = z.object({
|
|
11
|
+
id: z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(64)
|
|
15
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
16
|
+
});
|
|
17
|
+
// Shape only: the values' ranges are the slice's, so the editor gets field-by-field sentences.
|
|
18
|
+
const themeBody = z.object({ name: z.string(), values: z.unknown() });
|
|
19
|
+
// Library → Documents. The return type is inferred so Hono keeps the route types the SPA's
|
|
20
|
+
// client is generated from; see stagingRoutes.
|
|
21
|
+
export function documentThemeRoutes(deps) {
|
|
22
|
+
const library = { db: deps.db, ids: deps.ids, clock: deps.clock };
|
|
23
|
+
return (new Hono()
|
|
24
|
+
// The built-ins come with their values, so Duplicate starts from them.
|
|
25
|
+
.get("/", (c) => c.json({
|
|
26
|
+
builtIns: documentThemes.map((name) => ({
|
|
27
|
+
name,
|
|
28
|
+
label: documentThemeLabels[name],
|
|
29
|
+
values: builtInTheme(name),
|
|
30
|
+
})),
|
|
31
|
+
themes: listDocumentThemes(deps.db),
|
|
32
|
+
}))
|
|
33
|
+
.post("/", zValidator("json", themeBody, onInvalid), (c) => {
|
|
34
|
+
const result = createDocumentTheme(library, c.req.valid("json"));
|
|
35
|
+
return result.ok ? c.json(result.value, 201) : refused(c, result);
|
|
36
|
+
})
|
|
37
|
+
.put("/:id", zValidator("param", idParam, onInvalid), zValidator("json", themeBody, onInvalid), (c) => {
|
|
38
|
+
const result = updateDocumentTheme(library, c.req.valid("param").id, c.req.valid("json"));
|
|
39
|
+
return result.ok ? c.json(result.value) : refused(c, result);
|
|
40
|
+
})
|
|
41
|
+
// A project keeps its own copy of a theme's values, so nothing cascades.
|
|
42
|
+
.delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
43
|
+
const result = removeDocumentTheme(library, c.req.valid("param").id);
|
|
44
|
+
return result.ok ? c.body(null, 204) : refused(c, result);
|
|
45
|
+
})
|
|
46
|
+
// The editor's live preview: the sample article laid out with unsaved values.
|
|
47
|
+
.post("/preview", zValidator("json", z.object({ values: documentThemeSchema }), onInvalid), (c) => {
|
|
48
|
+
const rendered = renderSampleDocument(resolveTheme(c.req.valid("json").values));
|
|
49
|
+
return c.body(rendered.bytes.slice().buffer, 200, {
|
|
50
|
+
"Content-Type": "application/pdf",
|
|
51
|
+
"Cache-Control": "no-store",
|
|
52
|
+
});
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
function refused(c, failure) {
|
|
56
|
+
switch (failure.reason) {
|
|
57
|
+
case "invalid":
|
|
58
|
+
return problem(c, {
|
|
59
|
+
status: 400,
|
|
60
|
+
title: titleOf(400),
|
|
61
|
+
detail: "This theme can't be saved yet. Fix the highlighted settings and try again.",
|
|
62
|
+
extensions: { fields: failure.fields },
|
|
63
|
+
});
|
|
64
|
+
case "duplicate-name": {
|
|
65
|
+
const message = "Another document theme already has this name. Choose a different name.";
|
|
66
|
+
return problem(c, {
|
|
67
|
+
status: 409,
|
|
68
|
+
title: titleOf(409),
|
|
69
|
+
detail: message,
|
|
70
|
+
extensions: { fields: [{ field: "name", message }] },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
case "not-found":
|
|
74
|
+
return problem(c, {
|
|
75
|
+
status: 404,
|
|
76
|
+
title: titleOf(404),
|
|
77
|
+
detail: "This document theme no longer exists; it may have been deleted in another tab. Go back to Library → Documents.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { collectSharedGlossary } from "../../slices/narration/shared-glossary.js";
|
|
5
|
+
import { onInvalid } from "./problem.js";
|
|
6
|
+
// The pronunciations of every project's glossary, merged, for Edit project to copy into a
|
|
7
|
+
// project that shares them. `except` leaves out the project being edited, whose own glossary
|
|
8
|
+
// already comes first.
|
|
9
|
+
export function pronunciationRoutes(deps) {
|
|
10
|
+
return new Hono().get("/shared", zValidator("query", z.object({
|
|
11
|
+
except: z
|
|
12
|
+
.string()
|
|
13
|
+
.max(64)
|
|
14
|
+
.regex(/^[0-9A-Za-z_-]*$/)
|
|
15
|
+
.optional(),
|
|
16
|
+
}), onInvalid), (c) => c.json(collectSharedGlossary(deps, c.req.valid("query").except)));
|
|
17
|
+
}
|
|
@@ -1,39 +1,86 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import { BackupBusyError, planBackup, streamBackup, } from "../../slices/storage/backup-export.js";
|
|
3
|
+
import { BackupImportRefused, importBackup } from "../../slices/storage/backup-import.js";
|
|
4
|
+
import { importPortable, portableMaxArchiveBytes, storageUsage, } from "../../slices/storage/portable.js";
|
|
3
5
|
import { reconcileStorage } from "../../slices/storage/reconcile.js";
|
|
4
6
|
import { problem, titleOf } from "./problem.js";
|
|
7
|
+
const exportFailed = "Slopify could not create the backup. Check there is free disk space and try again; if it keeps failing, use Download diagnostics in Settings and report it.";
|
|
5
8
|
export function storageRoutes(deps) {
|
|
6
|
-
|
|
9
|
+
const backup = () => ({
|
|
10
|
+
db: deps.db,
|
|
11
|
+
paths: deps.paths,
|
|
12
|
+
clock: deps.clock,
|
|
13
|
+
ids: deps.ids,
|
|
14
|
+
appVersion: deps.version,
|
|
15
|
+
hasInflight: deps.runner.hasInflight,
|
|
16
|
+
});
|
|
17
|
+
return (new Hono()
|
|
7
18
|
.get("/", (c) => c.json(storageUsage({ db: deps.db, paths: deps.paths })))
|
|
8
|
-
|
|
19
|
+
// What Export everything would write, asked before the download starts: a download
|
|
20
|
+
// link cannot show a refusal, the browser would save it as the file.
|
|
21
|
+
.get("/export/summary", (c) => {
|
|
9
22
|
try {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return c.body(bytes, 200, {
|
|
17
|
-
"content-type": "application/zip",
|
|
18
|
-
"content-length": String(bytes.byteLength),
|
|
19
|
-
"content-disposition": 'attachment; filename="slopify-backup.zip"',
|
|
23
|
+
const plan = planBackup(backup());
|
|
24
|
+
return c.json({
|
|
25
|
+
ready: true,
|
|
26
|
+
projects: plan.manifest.projects.length,
|
|
27
|
+
files: plan.manifest.files,
|
|
28
|
+
bytes: plan.archiveBytes,
|
|
20
29
|
});
|
|
21
30
|
}
|
|
22
|
-
catch {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
});
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof BackupBusyError)
|
|
33
|
+
return c.json({ ready: false, detail: error.message, busy: error.projects });
|
|
34
|
+
deps.log.write("error", "storage.export", { detail: String(error) });
|
|
35
|
+
return problem(c, { status: 500, title: titleOf(500), detail: exportFailed });
|
|
28
36
|
}
|
|
37
|
+
})
|
|
38
|
+
.get("/export", (c) => {
|
|
39
|
+
let plan;
|
|
40
|
+
try {
|
|
41
|
+
plan = planBackup(backup());
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof BackupBusyError)
|
|
45
|
+
return problem(c, { status: 409, title: titleOf(409), detail: error.message });
|
|
46
|
+
deps.log.write("error", "storage.export", { detail: String(error) });
|
|
47
|
+
return problem(c, { status: 500, title: titleOf(500), detail: exportFailed });
|
|
48
|
+
}
|
|
49
|
+
// Pulled one chunk at a time, so a slow download reads files no faster than it sends.
|
|
50
|
+
const chunks = streamBackup(plan);
|
|
51
|
+
const body = new ReadableStream({
|
|
52
|
+
async pull(controller) {
|
|
53
|
+
try {
|
|
54
|
+
const next = await chunks.next();
|
|
55
|
+
if (next.done === true)
|
|
56
|
+
controller.close();
|
|
57
|
+
else
|
|
58
|
+
controller.enqueue(next.value);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
deps.log.write("error", "storage.export", { detail: String(error) });
|
|
62
|
+
controller.error(error);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
async cancel() {
|
|
66
|
+
await chunks.return(undefined);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return c.body(body, 200, {
|
|
70
|
+
"content-type": "application/x-tar",
|
|
71
|
+
"content-length": String(plan.archiveBytes),
|
|
72
|
+
"content-disposition": `attachment; filename="${plan.fileName}"`,
|
|
73
|
+
});
|
|
29
74
|
})
|
|
30
75
|
.put("/import", async (c) => {
|
|
31
76
|
const contentType = c.req.header("content-type") ?? "";
|
|
77
|
+
if (contentType.includes("application/x-tar"))
|
|
78
|
+
return importFull(c.req.raw);
|
|
32
79
|
if (!contentType.includes("application/zip"))
|
|
33
80
|
return problem(c, {
|
|
34
81
|
status: 415,
|
|
35
82
|
title: titleOf(415),
|
|
36
|
-
detail: "Choose a Slopify backup file
|
|
83
|
+
detail: "Choose a Slopify backup file: a .tar made with Export everything, or an older .zip made with Export backup, both in Settings → Backup & storage.",
|
|
37
84
|
});
|
|
38
85
|
const upload = await readPortableUpload(c.req.raw);
|
|
39
86
|
if (!upload.ok)
|
|
@@ -58,8 +105,46 @@ export function storageRoutes(deps) {
|
|
|
58
105
|
detail: "This file is not a complete Slopify backup; it may be damaged or a different kind of ZIP. Choose a backup made with Backup & storage in Settings.",
|
|
59
106
|
});
|
|
60
107
|
}
|
|
108
|
+
async function importFull(request) {
|
|
109
|
+
if (request.body === null)
|
|
110
|
+
return problem(c, {
|
|
111
|
+
status: 400,
|
|
112
|
+
title: titleOf(400),
|
|
113
|
+
detail: "The backup file arrived empty. Choose the .tar made with Export everything and import it again.",
|
|
114
|
+
});
|
|
115
|
+
const reader = request.body.getReader();
|
|
116
|
+
try {
|
|
117
|
+
return c.json(await importBackup(backup(), chunksOf(reader)));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof BackupImportRefused)
|
|
121
|
+
return problem(c, {
|
|
122
|
+
status: error.status,
|
|
123
|
+
title: titleOf(error.status),
|
|
124
|
+
detail: error.message,
|
|
125
|
+
});
|
|
126
|
+
deps.log.write("error", "storage.import", { detail: String(error) });
|
|
127
|
+
return problem(c, {
|
|
128
|
+
status: 500,
|
|
129
|
+
title: titleOf(500),
|
|
130
|
+
detail: "The backup wasn't imported and nothing in this install changed. Try again; if it keeps failing, use Download diagnostics in Settings and report it.",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
// A refusal mid-upload stops reading the rest of a file that may be gigabytes.
|
|
135
|
+
await reader.cancel().catch(() => undefined);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
61
138
|
})
|
|
62
|
-
.post("/cleanup", (c) => c.json(reconcileStorage(deps.db, deps.paths)));
|
|
139
|
+
.post("/cleanup", (c) => c.json(reconcileStorage(deps.db, deps.paths))));
|
|
140
|
+
}
|
|
141
|
+
async function* chunksOf(reader) {
|
|
142
|
+
while (true) {
|
|
143
|
+
const next = await reader.read();
|
|
144
|
+
if (next.done)
|
|
145
|
+
return;
|
|
146
|
+
yield next.value;
|
|
147
|
+
}
|
|
63
148
|
}
|
|
64
149
|
async function readPortableUpload(request) {
|
|
65
150
|
const declared = request.headers.get("content-length");
|
|
@@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs";
|
|
|
3
3
|
// under Vitest (src/kernel/db/migrations) and after the build (dist/kernel/db/migrations,
|
|
4
4
|
// filled by packages/app/scripts/copy-migrations.mjs).
|
|
5
5
|
const migrationsDir = new URL("./migrations/", import.meta.url);
|
|
6
|
-
export function migrate(db, clock) {
|
|
6
|
+
export function migrate(db, clock, options = {}) {
|
|
7
7
|
const files = readdirSync(migrationsDir)
|
|
8
8
|
.filter((file) => file.endsWith(".sql"))
|
|
9
9
|
.sort();
|
|
@@ -20,6 +20,9 @@ export function migrate(db, clock) {
|
|
|
20
20
|
if (version === undefined || applied.has(version)) {
|
|
21
21
|
continue;
|
|
22
22
|
}
|
|
23
|
+
if (options.through !== undefined && version > options.through) {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
23
26
|
const sql = readFileSync(new URL(file, migrationsDir), "utf8");
|
|
24
27
|
// A migration that rebuilds a table other tables reference must run with enforcement
|
|
25
28
|
// off: dropping the old table would otherwise cascade into every row that points at it.
|
|
@@ -51,6 +54,12 @@ export function migrate(db, clock) {
|
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
}
|
|
57
|
+
// The newest schema this build knows, which a backup records as the schema its rows fit.
|
|
58
|
+
export function newestMigration() {
|
|
59
|
+
return Math.max(0, ...readdirSync(migrationsDir)
|
|
60
|
+
.filter((file) => file.endsWith(".sql"))
|
|
61
|
+
.map(versionOf));
|
|
62
|
+
}
|
|
54
63
|
// The first line of such a migration, compared without its line ending.
|
|
55
64
|
const foreignKeysOff = "-- foreign-keys: off";
|
|
56
65
|
function foreignKeysOn(db) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Document themes saved in Library → Documents. A project keeps its own copy of the values
|
|
2
|
+
-- it was given, so nothing references these rows and a delete never cascades.
|
|
3
|
+
CREATE TABLE document_themes (
|
|
4
|
+
id TEXT PRIMARY KEY,
|
|
5
|
+
name TEXT NOT NULL,
|
|
6
|
+
values_json TEXT NOT NULL CHECK (json_valid(values_json)),
|
|
7
|
+
created_at TEXT NOT NULL,
|
|
8
|
+
updated_at TEXT NOT NULL
|
|
9
|
+
);
|
|
10
|
+
CREATE UNIQUE INDEX document_themes_name ON document_themes(lower(name));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- DiceMaster stopped being a built-in document theme: it is one brand's look (DiceMaster.io),
|
|
2
|
+
-- not Slopify's. An install that has already used the Document stage gets it back as a saved
|
|
3
|
+
-- Library → Documents theme with exactly the values it had, so it can still be picked for new
|
|
4
|
+
-- projects. Projects saved with it keep drawing with it either way (legacy-dicemaster.ts).
|
|
5
|
+
-- A fresh install gets nothing, and an existing theme of that name is left alone.
|
|
6
|
+
-- The JSON below is legacyDiceMasterTheme; legacy-dicemaster.test.ts holds the two equal.
|
|
7
|
+
INSERT INTO document_themes (id, name, values_json, created_at, updated_at)
|
|
8
|
+
SELECT 'legacy-dicemaster', 'DiceMaster',
|
|
9
|
+
'{"page":{"format":"a4","margin":22,"contentTop":32},"background":{"image":"parchment","color":"#f5ebd7"},"colors":{"heading":"#8c1e14","text":"#000000","muted":"#50463c","faint":"#786e5f"},"fonts":{"body":{"family":"Cinzel","style":"normal","letterSpacing":0.01},"strong":{"family":"Cinzel","style":"bold","letterSpacing":0.02},"emphasis":{"family":"times","style":"italic","letterSpacing":0},"heading":{"family":"Cinzel","style":"bold","letterSpacing":0.02},"dramatic":{"family":"Cinzel","style":"black","letterSpacing":0.03},"decorative":{"family":"Cinzel","style":"medium","letterSpacing":0.015},"dropCap":{"family":"Cinzel","style":"black","letterSpacing":0},"footer":{"family":"times","style":"normal","letterSpacing":0}},"sizes":{"brand":31,"title":22,"section":19,"heading":17,"subheading":13,"body":10.5,"footer":9,"meta":10},"spacing":{"bodyLine":8,"headingLine":11,"subheadingLine":9,"paragraphGap":0.5,"headingGap":1,"itemGap":0.2,"listIndent":6,"quoteIndent":8,"ruleWidth":30},"dropCap":{"enabled":true,"lines":3,"scale":3,"gap":4,"minLength":50,"minRoom":0.25},"titlePage":{"brandY":60,"taglineOffset":10,"titleY":80,"titleLine":10,"metaOffset":20,"metaLine":10,"showDate":true,"showWordCount":true,"cover":{"enabled":true,"gap":6,"maxHeight":110}},"contents":{"enabled":true,"title":"Table of Contents","depth":2,"titleOffset":20,"firstEntryOffset":40,"line":8,"indent":5},"header":{"enabled":true,"top":15,"maxTitleCharacters":35},"footer":{"enabled":true,"bottom":10,"reserve":15,"text":"Page {page}"},"brand":{"name":"DiceMaster.io","url":"https://dicemaster.io/","tagline":"Let us Handle the Crunch, You Focus on the Story","linkLabel":"Visit DiceMaster.io"},"metadata":{"author":"Created with DiceMaster.io","subject":"{title} - Powered by DiceMaster.io","keywords":"D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io","creator":"DiceMaster.io - The AI-Powered Virtual Tabletop"},"sources":{"enabled":true,"title":"Sources Consulted","titleOffset":20,"bodyOffset":40,"line":8,"gap":2},"endPage":{"enabled":true,"title":"About DiceMaster.io","titleOffset":20,"bodyOffset":40,"lines":["About DiceMaster.io:","• Character Engine - Form-driven creation with automatic leveling","• Automated Rules Engine - D&D mechanics and spells run automatically","• AI-Generated NPCs - Instant personalities with full stat blocks","• Granular Inventory Tracking - Live gear & condition management","• Real-Time Rule Guidance - Context-aware clarifications","• Dynamic Storytelling AI - On-the-fly narration & dialogue","• True-Physics Dice - RNG-driven rolls with animated suspense","• Cinematic Combat Module - Positional tracking & narrative summaries","• Voice Commands - Seamless STT/TTS multimodal interaction","• AI Dungeon Master Layer - Rules adjudication & encounter balancing"],"showDocumentDetails":true,"link":{"text":"Visit DiceMaster.io","url":"https://dicemaster.io/"},"closing":"Experience the future of tabletop RPGs!"}}',
|
|
10
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
11
|
+
WHERE EXISTS (SELECT 1 FROM stages WHERE kind = 'document' AND source <> 'off')
|
|
12
|
+
AND NOT EXISTS (SELECT 1 FROM document_themes WHERE lower(name) = 'dicemaster');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- One row per backup archive imported (Settings → Backup & storage). A backup carries its own
|
|
2
|
+
-- id, so importing the same file twice adds its usage history once.
|
|
3
|
+
CREATE TABLE backup_imports (
|
|
4
|
+
backup_id TEXT PRIMARY KEY,
|
|
5
|
+
imported_at TEXT NOT NULL,
|
|
6
|
+
summary_json TEXT NOT NULL CHECK (json_valid(summary_json))
|
|
7
|
+
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
3
3
|
import { checkpointStageSchema } from "../checkpoints/schema.js";
|
|
4
|
-
import {
|
|
4
|
+
import { documentSettingsSchema } from "../document/theme-schema.js";
|
|
5
5
|
import { chunkModes } from "../narration/chunk.js";
|
|
6
6
|
import { subtitleConfigSchema } from "../subtitles/model.js";
|
|
7
7
|
import { entryModes, formats, motionStyles, stageSources } from "./model.js";
|
|
@@ -37,7 +37,19 @@ export const runDraftSchema = z.object({
|
|
|
37
37
|
}),
|
|
38
38
|
llm: providerChoice.optional(),
|
|
39
39
|
audio: providerChoice
|
|
40
|
-
.extend({
|
|
40
|
+
.extend({
|
|
41
|
+
voice: z.string(),
|
|
42
|
+
usePronunciationGlossary: z.boolean().optional(),
|
|
43
|
+
shareGlossary: z.boolean().optional(),
|
|
44
|
+
})
|
|
45
|
+
.optional(),
|
|
46
|
+
sharedGlossary: z
|
|
47
|
+
.array(z.object({
|
|
48
|
+
term: z.string().min(1).max(200),
|
|
49
|
+
ipa: z.array(z.string().min(1).max(200)).min(1).max(20).readonly(),
|
|
50
|
+
}))
|
|
51
|
+
.max(20000)
|
|
52
|
+
.readonly()
|
|
41
53
|
.optional(),
|
|
42
54
|
images: providerChoice.optional(),
|
|
43
55
|
articlePrompt: z.string().optional(),
|
|
@@ -71,7 +83,7 @@ export const runDraftSchema = z.object({
|
|
|
71
83
|
motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
|
|
72
84
|
edgeSilenceSeconds: z.number().default(defaultEdgeSilenceSeconds),
|
|
73
85
|
subtitles: subtitleConfigSchema.optional(),
|
|
74
|
-
document:
|
|
86
|
+
document: documentSettingsSchema.optional(),
|
|
75
87
|
youtubeDescription: z.boolean().optional(),
|
|
76
88
|
descriptionPrompt: z.string().optional(),
|
|
77
89
|
});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { transact } from "../../kernel/db/tx.js";
|
|
2
2
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
3
3
|
import { storeArticleText } from "../article/store.js";
|
|
4
|
+
import { defaultDocumentTheme } from "../document/model.js";
|
|
5
|
+
import { collectSharedGlossary } from "../narration/shared-glossary.js";
|
|
4
6
|
import { admitInitialRevision } from "../rebuild/runtime-admission.js";
|
|
5
7
|
import { adoptBaseline } from "../revisions/adopt.js";
|
|
6
8
|
import { attachStagedFile, storeText } from "../storage/staging.js";
|
|
7
9
|
import { releaseStagedFile } from "../storage/staging-refs.js";
|
|
8
10
|
import { sourceOf, } from "./model.js";
|
|
9
11
|
import { insertProject, insertStage } from "./repo.js";
|
|
12
|
+
import { usesPronunciationGlossary } from "./rules.js";
|
|
10
13
|
// Provide → `provided` with its output attached; Off → `skipped`;
|
|
11
14
|
// everything else → `pending`.
|
|
12
15
|
export function initialState(source) {
|
|
@@ -20,7 +23,19 @@ export function initialState(source) {
|
|
|
20
23
|
export function startRun(deps, draft, rendered, retainStaged = false, templates) {
|
|
21
24
|
const id = deps.ids.next();
|
|
22
25
|
const at = deps.clock.now().toISOString();
|
|
23
|
-
const
|
|
26
|
+
const shared = usesPronunciationGlossary(draft) && draft.audio?.shareGlossary === true
|
|
27
|
+
? collectSharedGlossary(deps).entries
|
|
28
|
+
: [];
|
|
29
|
+
const config = {
|
|
30
|
+
...draft,
|
|
31
|
+
// A new project always names its document theme: a config without one reads as the
|
|
32
|
+
// DiceMaster of older projects (see documentThemeOf), which no new project should get.
|
|
33
|
+
...(sourceOf(draft.sources, "document") === "generate" && draft.document === undefined
|
|
34
|
+
? { document: { theme: defaultDocumentTheme } }
|
|
35
|
+
: {}),
|
|
36
|
+
rendered,
|
|
37
|
+
...(shared.length === 0 ? {} : { sharedGlossary: shared }),
|
|
38
|
+
};
|
|
24
39
|
const project = {
|
|
25
40
|
id,
|
|
26
41
|
title: draft.title,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The entries of an article's "Sources Consulted" section, one per source. Models write the
|
|
2
|
+
// list as bullets, as numbered lines, or as plain lines with no blank line between them;
|
|
3
|
+
// markdown would join the last kind into one paragraph, so the section is read line by line
|
|
4
|
+
// instead. A line indented under an entry continues it. The section starts with its own
|
|
5
|
+
// heading (`splitEndMatter` cuts it there), which is not an entry. Browser-safe: the project
|
|
6
|
+
// page lists the same entries the PDF prints.
|
|
7
|
+
const marker = /^(?:[-*+]|\d+[.)])\s+/u;
|
|
8
|
+
export function sourceEntries(section) {
|
|
9
|
+
const lines = section.split(/\r?\n/u).filter((line) => line.trim() !== "");
|
|
10
|
+
const entries = [];
|
|
11
|
+
for (const raw of lines.slice(1)) {
|
|
12
|
+
const line = raw.trim().replace(marker, "").trim();
|
|
13
|
+
const last = entries.length - 1;
|
|
14
|
+
if (/^\s/u.test(raw) && !marker.test(raw.trim()) && last >= 0)
|
|
15
|
+
entries[last] = `${entries[last] ?? ""} ${line}`;
|
|
16
|
+
else if (line !== "")
|
|
17
|
+
entries.push(line);
|
|
18
|
+
}
|
|
19
|
+
return entries;
|
|
20
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The DiceMaster look, the built-in default before 2.3.0. It is one brand's look (DiceMaster.io),
|
|
2
|
+
// not one Slopify offers, so no picker and no built-in list shows it any more. It stays in the
|
|
3
|
+
// code for two reasons only: a project saved with `document: { theme: "dicemaster" }`, or
|
|
4
|
+
// with the Document stage on and no theme at all, must keep drawing (and fingerprinting) exactly
|
|
5
|
+
// as it did, or its PDF would read as outdated; and migration 0017 copies these values into
|
|
6
|
+
// Library → Documents as a saved "DiceMaster" theme, which a test holds to this literal.
|
|
7
|
+
//
|
|
8
|
+
// Ported from the lore2script2 PDF generator's stock DiceMaster.io look, value for value. Do not
|
|
9
|
+
// edit it: any change reaches the documents of every project that still uses it.
|
|
10
|
+
const cinzel = (style, letterSpacing) => ({
|
|
11
|
+
family: "Cinzel",
|
|
12
|
+
style,
|
|
13
|
+
letterSpacing,
|
|
14
|
+
});
|
|
15
|
+
export const legacyDiceMasterTheme = {
|
|
16
|
+
page: { format: "a4", margin: 22, contentTop: 32 },
|
|
17
|
+
background: { image: "parchment", color: "#f5ebd7" },
|
|
18
|
+
colors: { heading: "#8c1e14", text: "#000000", muted: "#50463c", faint: "#786e5f" },
|
|
19
|
+
fonts: {
|
|
20
|
+
body: cinzel("normal", 0.01),
|
|
21
|
+
strong: cinzel("bold", 0.02),
|
|
22
|
+
// Cinzel has no italic, so emphasis borrows the standard serif's.
|
|
23
|
+
emphasis: { family: "times", style: "italic", letterSpacing: 0 },
|
|
24
|
+
heading: cinzel("bold", 0.02),
|
|
25
|
+
dramatic: cinzel("black", 0.03),
|
|
26
|
+
decorative: cinzel("medium", 0.015),
|
|
27
|
+
dropCap: cinzel("black", 0),
|
|
28
|
+
footer: { family: "times", style: "normal", letterSpacing: 0 },
|
|
29
|
+
},
|
|
30
|
+
// lore2script2 scaled its Cinzel sizes and line heights (by 1.1, 1.05 and 1.2) and
|
|
31
|
+
// rounded; these are the results.
|
|
32
|
+
sizes: {
|
|
33
|
+
brand: 31,
|
|
34
|
+
title: 22,
|
|
35
|
+
section: 19,
|
|
36
|
+
heading: 17,
|
|
37
|
+
subheading: 13,
|
|
38
|
+
body: 10.5,
|
|
39
|
+
footer: 9,
|
|
40
|
+
meta: 10,
|
|
41
|
+
},
|
|
42
|
+
spacing: {
|
|
43
|
+
bodyLine: 8,
|
|
44
|
+
headingLine: 11,
|
|
45
|
+
subheadingLine: 9,
|
|
46
|
+
paragraphGap: 0.5,
|
|
47
|
+
headingGap: 1,
|
|
48
|
+
itemGap: 0.2,
|
|
49
|
+
listIndent: 6,
|
|
50
|
+
quoteIndent: 8,
|
|
51
|
+
ruleWidth: 30,
|
|
52
|
+
},
|
|
53
|
+
dropCap: { enabled: true, lines: 3, scale: 3, gap: 4, minLength: 50, minRoom: 0.25 },
|
|
54
|
+
titlePage: {
|
|
55
|
+
brandY: 60,
|
|
56
|
+
taglineOffset: 10,
|
|
57
|
+
titleY: 80,
|
|
58
|
+
titleLine: 10,
|
|
59
|
+
metaOffset: 20,
|
|
60
|
+
metaLine: 10,
|
|
61
|
+
showDate: true,
|
|
62
|
+
showWordCount: true,
|
|
63
|
+
cover: { enabled: true, gap: 6, maxHeight: 110 },
|
|
64
|
+
},
|
|
65
|
+
contents: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
title: "Table of Contents",
|
|
68
|
+
depth: 2,
|
|
69
|
+
titleOffset: 20,
|
|
70
|
+
firstEntryOffset: 40,
|
|
71
|
+
line: 8,
|
|
72
|
+
indent: 5,
|
|
73
|
+
},
|
|
74
|
+
header: { enabled: true, top: 15, maxTitleCharacters: 35 },
|
|
75
|
+
footer: { enabled: true, bottom: 10, reserve: 15, text: "Page {page}" },
|
|
76
|
+
brand: {
|
|
77
|
+
name: "DiceMaster.io",
|
|
78
|
+
url: "https://dicemaster.io/",
|
|
79
|
+
tagline: "Let us Handle the Crunch, You Focus on the Story",
|
|
80
|
+
linkLabel: "Visit DiceMaster.io",
|
|
81
|
+
},
|
|
82
|
+
metadata: {
|
|
83
|
+
author: "Created with DiceMaster.io",
|
|
84
|
+
subject: "{title} - Powered by DiceMaster.io",
|
|
85
|
+
keywords: "D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io",
|
|
86
|
+
creator: "DiceMaster.io - The AI-Powered Virtual Tabletop",
|
|
87
|
+
},
|
|
88
|
+
sources: {
|
|
89
|
+
enabled: true,
|
|
90
|
+
title: "Sources Consulted",
|
|
91
|
+
titleOffset: 20,
|
|
92
|
+
bodyOffset: 40,
|
|
93
|
+
line: 8,
|
|
94
|
+
gap: 2,
|
|
95
|
+
},
|
|
96
|
+
endPage: {
|
|
97
|
+
enabled: true,
|
|
98
|
+
title: "About DiceMaster.io",
|
|
99
|
+
titleOffset: 20,
|
|
100
|
+
bodyOffset: 40,
|
|
101
|
+
lines: [
|
|
102
|
+
"About DiceMaster.io:",
|
|
103
|
+
"• Character Engine - Form-driven creation with automatic leveling",
|
|
104
|
+
"• Automated Rules Engine - D&D mechanics and spells run automatically",
|
|
105
|
+
"• AI-Generated NPCs - Instant personalities with full stat blocks",
|
|
106
|
+
"• Granular Inventory Tracking - Live gear & condition management",
|
|
107
|
+
"• Real-Time Rule Guidance - Context-aware clarifications",
|
|
108
|
+
"• Dynamic Storytelling AI - On-the-fly narration & dialogue",
|
|
109
|
+
"• True-Physics Dice - RNG-driven rolls with animated suspense",
|
|
110
|
+
"• Cinematic Combat Module - Positional tracking & narrative summaries",
|
|
111
|
+
"• Voice Commands - Seamless STT/TTS multimodal interaction",
|
|
112
|
+
"• AI Dungeon Master Layer - Rules adjudication & encounter balancing",
|
|
113
|
+
],
|
|
114
|
+
showDocumentDetails: true,
|
|
115
|
+
link: { text: "Visit DiceMaster.io", url: "https://dicemaster.io/" },
|
|
116
|
+
closing: "Experience the future of tabletop RPGs!",
|
|
117
|
+
},
|
|
118
|
+
};
|