@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.
Files changed (43) hide show
  1. package/dist/assets/document/SOURCE.txt +5 -1
  2. package/dist/assets/document/sample-cover.jpg +0 -0
  3. package/dist/edge/http/app.js +4 -0
  4. package/dist/edge/http/document-themes.js +80 -0
  5. package/dist/edge/http/pronunciations.js +17 -0
  6. package/dist/edge/http/storage.js +106 -21
  7. package/dist/kernel/db/migrate.js +10 -1
  8. package/dist/kernel/db/migrations/0016-document-themes.sql +10 -0
  9. package/dist/kernel/db/migrations/0017-legacy-dicemaster-theme.sql +12 -0
  10. package/dist/kernel/db/migrations/0018-backup-imports.sql +7 -0
  11. package/dist/slices/admission/schema.js +15 -3
  12. package/dist/slices/admission/start.js +16 -1
  13. package/dist/slices/article/source-lines.js +20 -0
  14. package/dist/slices/document/legacy-dicemaster.js +118 -0
  15. package/dist/slices/document/library.js +77 -0
  16. package/dist/slices/document/model.js +40 -5
  17. package/dist/slices/document/sample.js +51 -0
  18. package/dist/slices/document/sources.js +5 -10
  19. package/dist/slices/document/theme-schema.js +173 -0
  20. package/dist/slices/document/theme.js +25 -44
  21. package/dist/slices/narration/shared-glossary.js +51 -0
  22. package/dist/slices/play-drafts/convert.js +12 -3
  23. package/dist/slices/play-drafts/schema.js +7 -7
  24. package/dist/slices/project-templates/from-project.js +9 -1
  25. package/dist/slices/rebuild/preview-details.js +9 -2
  26. package/dist/slices/rebuild/recipe-document.js +2 -3
  27. package/dist/slices/rebuild/recipe-text.js +9 -1
  28. package/dist/slices/rebuild/runtime-document.js +2 -3
  29. package/dist/slices/rebuild/runtime-store.js +19 -3
  30. package/dist/slices/storage/backup-export.js +321 -0
  31. package/dist/slices/storage/backup-format.js +155 -0
  32. package/dist/slices/storage/backup-import.js +749 -0
  33. package/dist/slices/storage/portable.js +27 -1
  34. package/dist/slices/storage/tar.js +257 -0
  35. package/dist/web/assets/index-Cw1mnRrx.js +168 -0
  36. package/dist/web/assets/index-kAKdJa-U.css +1 -0
  37. package/dist/web/assets/pdf-BkdJFKQ-.js +54 -0
  38. package/dist/web/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
  39. package/dist/web/assets/pdf.worker.min-zC6nfbxG.js +1 -0
  40. package/dist/web/index.html +2 -2
  41. package/package.json +1 -1
  42. package/dist/web/assets/index-B6NbhpjD.css +0 -1
  43. package/dist/web/assets/index-DV01Cyjx.js +0 -142
@@ -0,0 +1,77 @@
1
+ import { isUniqueConstraint } from "../../kernel/db/index.js";
2
+ import { documentThemeNameProblems } from "./model.js";
3
+ import { documentThemeSchema } from "./theme-schema.js";
4
+ export function listDocumentThemes(db) {
5
+ return db
6
+ .prepare("SELECT id,name,values_json,updated_at FROM document_themes ORDER BY lower(name),id")
7
+ .all()
8
+ .flatMap((row) => {
9
+ // A row whose values no longer parse (hand-edited, or from a newer Slopify) is left
10
+ // out rather than breaking the whole list.
11
+ const values = documentThemeSchema.safeParse(JSON.parse(String(row.values_json)));
12
+ return values.success
13
+ ? [
14
+ {
15
+ id: String(row.id),
16
+ name: String(row.name),
17
+ values: values.data,
18
+ updatedAt: String(row.updated_at),
19
+ },
20
+ ]
21
+ : [];
22
+ });
23
+ }
24
+ export function createDocumentTheme(deps, draft) {
25
+ const checked = check(draft);
26
+ if (!checked.ok)
27
+ return checked;
28
+ const now = deps.clock.now().toISOString();
29
+ const theme = { id: deps.ids.next(), ...checked.value, updatedAt: now };
30
+ return written(() => {
31
+ deps.db
32
+ .prepare("INSERT INTO document_themes(id,name,values_json,created_at,updated_at) VALUES (?,?,?,?,?)")
33
+ .run(theme.id, theme.name, JSON.stringify(theme.values), now, now);
34
+ return true;
35
+ }, theme);
36
+ }
37
+ export function updateDocumentTheme(deps, id, draft) {
38
+ const checked = check(draft);
39
+ if (!checked.ok)
40
+ return checked;
41
+ const now = deps.clock.now().toISOString();
42
+ const theme = { id, ...checked.value, updatedAt: now };
43
+ return written(() => deps.db
44
+ .prepare("UPDATE document_themes SET name=?,values_json=?,updated_at=? WHERE id=?")
45
+ .run(theme.name, JSON.stringify(theme.values), now, id).changes > 0, theme);
46
+ }
47
+ export function removeDocumentTheme(deps, id) {
48
+ return deps.db.prepare("DELETE FROM document_themes WHERE id=?").run(id).changes > 0
49
+ ? { ok: true, value: null }
50
+ : { ok: false, reason: "not-found" };
51
+ }
52
+ function check(draft) {
53
+ const named = documentThemeNameProblems(draft.name);
54
+ const values = documentThemeSchema.safeParse(draft.values);
55
+ const fields = [
56
+ ...named,
57
+ ...(values.success
58
+ ? []
59
+ : values.error.issues.map((issue) => ({
60
+ field: `values.${issue.path.join(".")}`,
61
+ message: issue.message,
62
+ }))),
63
+ ];
64
+ if (fields.length > 0 || !values.success)
65
+ return { ok: false, reason: "invalid", fields };
66
+ return { ok: true, value: { name: draft.name.trim(), values: values.data } };
67
+ }
68
+ function written(write, value) {
69
+ try {
70
+ return write() ? { ok: true, value } : { ok: false, reason: "not-found" };
71
+ }
72
+ catch (error) {
73
+ if (isUniqueConstraint(error))
74
+ return { ok: false, reason: "duplicate-name" };
75
+ throw error;
76
+ }
77
+ }
@@ -1,11 +1,46 @@
1
- // The built-in looks a project's PDF can take. Browser-safe: Play, Edit project and the
2
- // server all offer and accept the same names from here.
3
- export const documentThemes = ["dicemaster", "plain"];
4
- export const defaultDocumentTheme = "dicemaster";
1
+ // The built-in looks a project's PDF can be given. Browser-safe: Play, Edit project and the
2
+ // server all offer the same names from here.
3
+ export const documentThemes = ["plain"];
4
+ // DiceMaster was built in before 2.3.0 and is one brand's look, not Slopify's. A project
5
+ // saved with it keeps it (see legacy-dicemaster.ts), so the name is still read everywhere a
6
+ // config is parsed, but it is never offered.
7
+ export const legacyDocumentTheme = "dicemaster";
8
+ // Every name a saved config may carry.
9
+ export const documentThemeNames = [...documentThemes, legacyDocumentTheme];
10
+ // What a new project, a fresh Play draft and a draft saved before the Document stage get.
11
+ export const defaultDocumentTheme = "plain";
5
12
  export const documentThemeLabels = {
6
- dicemaster: "DiceMaster",
7
13
  plain: "Plain",
14
+ dicemaster: "DiceMaster",
8
15
  };
16
+ // The built-in a saved project config is drawn with. A project saved before the Document stage
17
+ // had a theme setting has none, and was drawn with DiceMaster, the default of the day; it still
18
+ // is, so the PDFs it already made stay current. New projects always save their theme, so this
19
+ // fallback never reaches one.
9
20
  export function documentThemeOf(settings) {
21
+ return settings?.theme ?? legacyDocumentTheme;
22
+ }
23
+ // The built-in a Play draft or template form stands for. A draft is not a project yet, so an
24
+ // absent setting is the current default rather than the old one.
25
+ export function draftDocumentThemeOf(settings) {
10
26
  return settings?.theme ?? defaultDocumentTheme;
11
27
  }
28
+ // The name a project's theme is shown by: the Library theme's own, or the built-in's label.
29
+ export function documentThemeLabel(settings) {
30
+ return settings?.custom?.name ?? documentThemeLabels[documentThemeOf(settings)];
31
+ }
32
+ export const documentThemeNameMax = 80;
33
+ // The editor's sentences for a name, shared so the page refuses what the server would.
34
+ export function documentThemeNameProblems(name) {
35
+ const trimmed = name.trim();
36
+ if (trimmed === "")
37
+ return [{ field: "name", message: "Give the theme a name." }];
38
+ if (trimmed.length > documentThemeNameMax)
39
+ return [
40
+ {
41
+ field: "name",
42
+ message: `Keep the name under ${String(documentThemeNameMax)} characters.`,
43
+ },
44
+ ];
45
+ return [];
46
+ }
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readDocumentAssets } from "./fonts.js";
3
+ import { renderDocument } from "./render.js";
4
+ // What the Library theme editor previews: a short article with every kind of block the
5
+ // renderer styles (levels of heading, a drop-cap paragraph, emphasis, a link, a list, a
6
+ // quote, a rule and a sources list), a stand-in cover and a fixed date, so only the theme
7
+ // changes between previews.
8
+ const title = "The Lighthouse at Grey Point";
9
+ const article = `# ${title}
10
+
11
+ The lighthouse at Grey Point has warned ships off the northern reef for nearly two centuries, and its keepers kept **careful logs** of every storm, wreck and *strange light* seen from the gallery. Most of those logs survive in the [county archive](https://example.com/archive), and they read like a slow, patient novel.
12
+
13
+ ## Chapter 1: The First Keepers
14
+
15
+ The first keeper arrived in the spring with a wife, two children and a crate of lamp oil that was meant to last the winter. It lasted six weeks, and the rest of that season was lit by whale oil bought from passing boats at ruinous prices.
16
+
17
+ - The tower was finished in 1834
18
+ - The lamp was replaced twice before 1900
19
+ - The last keeper left in 1961
20
+
21
+ > The sea does not care what the light costs.
22
+
23
+ ### A note on the logs
24
+
25
+ Entries are short, written in pencil, and nearly always begin with the wind.
26
+
27
+ ## Chapter 2: The Automatic Light
28
+
29
+ When the light was automated, the cottage stood empty for thirty years. Walkers still report seeing a lamp in its window on winter evenings, although the power was cut long ago and the door has been bricked up since the last survey.
30
+
31
+ ---
32
+
33
+ The tower is open to visitors on summer weekends.
34
+
35
+ ## Sources Consulted
36
+
37
+ - [County Archive: Grey Point Logs](https://example.com/logs)
38
+ - Coastal Lights of the North, second edition
39
+ `;
40
+ const cover = new URL("../../assets/document/sample-cover.jpg", import.meta.url);
41
+ export function renderSampleDocument(theme) {
42
+ return renderDocument({
43
+ title,
44
+ articleMarkdown: article,
45
+ researchNotes: null,
46
+ cover: new Uint8Array(readFileSync(cover)),
47
+ theme,
48
+ writtenOn: new Date("2026-01-15T12:00:00Z"),
49
+ assets: readDocumentAssets(),
50
+ });
51
+ }
@@ -1,3 +1,4 @@
1
+ import { sourceEntries } from "../article/source-lines.js";
1
2
  import { splitEndMatter } from "../article/split.js";
2
3
  import { markdownBlocks, markdownLinks, runsText } from "./blocks.js";
3
4
  // The pronunciation glossary is narration's business and is left out; the sources section
@@ -16,18 +17,12 @@ export function documentText(articleMarkdown, researchNotes) {
16
17
  return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited] };
17
18
  }
18
19
  function sourceItems(section) {
19
- if (section.trim() === "")
20
- return [];
21
- // The section's own heading, whether a `#` line or a bold paragraph, is its first block.
22
- return markdownBlocks(section)
23
- .slice(1)
24
- .flatMap((block) => {
25
- if (block.kind !== "item" && block.kind !== "paragraph" && block.kind !== "quote")
26
- return [];
27
- const text = runsText(block.runs).trim();
20
+ return sourceEntries(section).flatMap((entry) => {
21
+ const runs = markdownBlocks(entry).flatMap((block) => ("runs" in block ? block.runs : []));
22
+ const text = runsText(runs).trim();
28
23
  if (text === "")
29
24
  return [];
30
- const href = block.runs.find((run) => run.href !== null)?.href ??
25
+ const href = runs.find((run) => run.href !== null)?.href ??
31
26
  /https?:\/\/[^\s)>\]]+/.exec(text)?.[0] ??
32
27
  null;
33
28
  return [{ text, href }];
@@ -0,0 +1,173 @@
1
+ import { z } from "zod";
2
+ import { documentThemeNameMax, documentThemeNames } from "./model.js";
3
+ import { fontFamilies, fontStyles } from "./theme.js";
4
+ // Every value of a document theme with the range the renderer draws sensibly within, so a
5
+ // saved theme, a project config and the editor's preview all refuse the same values.
6
+ // Browser-safe: the Library editor reads the limits from here too.
7
+ const mm = (min, max) => {
8
+ const range = { error: `Use a value from ${String(min)} to ${String(max)}.` };
9
+ return z.number({ error: "Enter a number." }).finite(range).min(min, range).max(max, range);
10
+ };
11
+ const color = z.string().regex(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i, "Use a colour like #8c1e14.");
12
+ const line = z.string().max(300, { error: "Keep this under 300 characters." });
13
+ const nullableLine = line.nullable();
14
+ const url = z.string().max(500, { error: "Keep this under 500 characters." }).nullable();
15
+ const face = z
16
+ .object({
17
+ family: z.enum(fontFamilies),
18
+ style: z.enum(fontStyles),
19
+ letterSpacing: mm(-0.5, 2),
20
+ })
21
+ .strict();
22
+ export const documentThemeSchema = z
23
+ .object({
24
+ page: z
25
+ .object({
26
+ format: z.enum(["a4", "letter"]),
27
+ margin: mm(5, 60),
28
+ contentTop: mm(5, 80),
29
+ })
30
+ .strict(),
31
+ background: z.object({ image: z.enum(["parchment"]).nullable(), color }).strict(),
32
+ colors: z.object({ heading: color, text: color, muted: color, faint: color }).strict(),
33
+ fonts: z
34
+ .object({
35
+ body: face,
36
+ strong: face,
37
+ emphasis: face,
38
+ heading: face,
39
+ dramatic: face,
40
+ decorative: face,
41
+ dropCap: face,
42
+ footer: face,
43
+ })
44
+ .strict(),
45
+ sizes: z
46
+ .object({
47
+ brand: mm(6, 72),
48
+ title: mm(6, 72),
49
+ section: mm(6, 72),
50
+ heading: mm(6, 72),
51
+ subheading: mm(6, 72),
52
+ body: mm(6, 24),
53
+ footer: mm(5, 24),
54
+ meta: mm(5, 24),
55
+ })
56
+ .strict(),
57
+ spacing: z
58
+ .object({
59
+ bodyLine: mm(3, 20),
60
+ headingLine: mm(3, 30),
61
+ subheadingLine: mm(3, 30),
62
+ paragraphGap: mm(0, 3),
63
+ headingGap: mm(0, 5),
64
+ itemGap: mm(0, 3),
65
+ listIndent: mm(0, 30),
66
+ quoteIndent: mm(0, 40),
67
+ ruleWidth: mm(0, 150),
68
+ })
69
+ .strict(),
70
+ dropCap: z
71
+ .object({
72
+ enabled: z.boolean(),
73
+ lines: z.union([z.literal(2), z.literal(3), z.literal(4)]),
74
+ scale: mm(1, 5),
75
+ gap: mm(0, 20),
76
+ minLength: z
77
+ .number({ error: "Enter a number." })
78
+ .int({ error: "Use a whole number." })
79
+ .min(0, { error: "Use a value from 0 to 2000." })
80
+ .max(2000, { error: "Use a value from 0 to 2000." }),
81
+ minRoom: mm(0, 1),
82
+ })
83
+ .strict(),
84
+ titlePage: z
85
+ .object({
86
+ brandY: mm(10, 200),
87
+ taglineOffset: mm(0, 60),
88
+ titleY: mm(10, 250),
89
+ titleLine: mm(3, 40),
90
+ metaOffset: mm(0, 100),
91
+ metaLine: mm(3, 30),
92
+ showDate: z.boolean(),
93
+ showWordCount: z.boolean(),
94
+ cover: z.object({ enabled: z.boolean(), gap: mm(0, 40), maxHeight: mm(20, 200) }).strict(),
95
+ })
96
+ .strict(),
97
+ contents: z
98
+ .object({
99
+ enabled: z.boolean(),
100
+ title: line,
101
+ depth: z.union([z.literal(1), z.literal(2), z.literal(3)]),
102
+ titleOffset: mm(0, 100),
103
+ firstEntryOffset: mm(0, 150),
104
+ line: mm(3, 30),
105
+ indent: mm(0, 30),
106
+ })
107
+ .strict(),
108
+ header: z
109
+ .object({
110
+ enabled: z.boolean(),
111
+ top: mm(3, 60),
112
+ maxTitleCharacters: z
113
+ .number({ error: "Enter a number." })
114
+ .int({ error: "Use a whole number." })
115
+ .min(5, { error: "Use a value from 5 to 200." })
116
+ .max(200, { error: "Use a value from 5 to 200." }),
117
+ })
118
+ .strict(),
119
+ footer: z
120
+ .object({
121
+ enabled: z.boolean(),
122
+ bottom: mm(3, 60),
123
+ reserve: mm(0, 60),
124
+ text: line,
125
+ })
126
+ .strict(),
127
+ brand: z
128
+ .object({
129
+ name: nullableLine,
130
+ url,
131
+ tagline: nullableLine,
132
+ linkLabel: nullableLine,
133
+ })
134
+ .strict(),
135
+ metadata: z
136
+ .object({ author: line, subject: line, keywords: z.string().max(1000), creator: line })
137
+ .strict(),
138
+ sources: z
139
+ .object({
140
+ enabled: z.boolean(),
141
+ title: line,
142
+ titleOffset: mm(0, 100),
143
+ bodyOffset: mm(0, 150),
144
+ line: mm(3, 30),
145
+ gap: mm(0, 20),
146
+ })
147
+ .strict(),
148
+ endPage: z
149
+ .object({
150
+ enabled: z.boolean(),
151
+ title: line,
152
+ titleOffset: mm(0, 100),
153
+ bodyOffset: mm(0, 150),
154
+ lines: z.array(line).max(60).readonly(),
155
+ showDocumentDetails: z.boolean(),
156
+ link: z.object({ text: line, url }).strict().nullable(),
157
+ closing: nullableLine,
158
+ })
159
+ .strict(),
160
+ })
161
+ .strict();
162
+ export const customDocumentThemeSchema = z
163
+ .object({
164
+ id: z.string().min(1).max(64),
165
+ name: z.string().min(1).max(documentThemeNameMax),
166
+ values: documentThemeSchema,
167
+ })
168
+ .strict();
169
+ // A project's, a draft's and a template's document settings. The retired "dicemaster" still
170
+ // parses, so a project or draft saved with it keeps opening.
171
+ export const documentSettingsSchema = z
172
+ .object({ theme: z.enum(documentThemeNames), custom: customDocumentThemeSchema.optional() })
173
+ .strict();
@@ -1,4 +1,5 @@
1
- import { defaultDocumentTheme } from "./model.js";
1
+ import { legacyDiceMasterTheme } from "./legacy-dicemaster.js";
2
+ import { defaultDocumentTheme, documentThemeOf, legacyDocumentTheme, } from "./model.js";
2
3
  // "Cinzel" is bundled (assets/document/fonts) in the styles below; the other three are the
3
4
  // PDF standard fonts every reader has.
4
5
  export const fontFamilies = ["Cinzel", "times", "helvetica", "courier"];
@@ -8,10 +9,12 @@ const cinzel = (style, letterSpacing) => ({
8
9
  style,
9
10
  letterSpacing,
10
11
  });
11
- const dicemaster = {
12
+ // The one built-in: an unbranded flat page. Its layout is the one Slopify has always drawn,
13
+ // written out in full rather than as changes to another theme.
14
+ const plain = {
12
15
  page: { format: "a4", margin: 22, contentTop: 32 },
13
- background: { image: "parchment", color: "#f5ebd7" },
14
- colors: { heading: "#8c1e14", text: "#000000", muted: "#50463c", faint: "#786e5f" },
16
+ background: { image: null, color: "#fdfaf3" },
17
+ colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
15
18
  fonts: {
16
19
  body: cinzel("normal", 0.01),
17
20
  strong: cinzel("bold", 0.02),
@@ -23,8 +26,6 @@ const dicemaster = {
23
26
  dropCap: cinzel("black", 0),
24
27
  footer: { family: "times", style: "normal", letterSpacing: 0 },
25
28
  },
26
- // lore2script2 scaled its Cinzel sizes and line heights (by 1.1, 1.05 and 1.2) and
27
- // rounded; these are the results.
28
29
  sizes: {
29
30
  brand: 31,
30
31
  title: 22,
@@ -69,18 +70,8 @@ const dicemaster = {
69
70
  },
70
71
  header: { enabled: true, top: 15, maxTitleCharacters: 35 },
71
72
  footer: { enabled: true, bottom: 10, reserve: 15, text: "Page {page}" },
72
- brand: {
73
- name: "DiceMaster.io",
74
- url: "https://dicemaster.io/",
75
- tagline: "Let us Handle the Crunch, You Focus on the Story",
76
- linkLabel: "Visit DiceMaster.io",
77
- },
78
- metadata: {
79
- author: "Created with DiceMaster.io",
80
- subject: "{title} - Powered by DiceMaster.io",
81
- keywords: "D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io",
82
- creator: "DiceMaster.io - The AI-Powered Virtual Tabletop",
83
- },
73
+ brand: { name: null, url: null, tagline: null, linkLabel: null },
74
+ metadata: { author: "", subject: "{title}", keywords: "", creator: "" },
84
75
  sources: {
85
76
  enabled: true,
86
77
  title: "Sources Consulted",
@@ -90,43 +81,33 @@ const dicemaster = {
90
81
  gap: 2,
91
82
  },
92
83
  endPage: {
93
- enabled: true,
94
- title: "About DiceMaster.io",
84
+ enabled: false,
85
+ title: "About",
95
86
  titleOffset: 20,
96
87
  bodyOffset: 40,
97
- lines: [
98
- "About DiceMaster.io:",
99
- "• Character Engine - Form-driven creation with automatic leveling",
100
- "• Automated Rules Engine - D&D mechanics and spells run automatically",
101
- "• AI-Generated NPCs - Instant personalities with full stat blocks",
102
- "• Granular Inventory Tracking - Live gear & condition management",
103
- "• Real-Time Rule Guidance - Context-aware clarifications",
104
- "• Dynamic Storytelling AI - On-the-fly narration & dialogue",
105
- "• True-Physics Dice - RNG-driven rolls with animated suspense",
106
- "• Cinematic Combat Module - Positional tracking & narrative summaries",
107
- "• Voice Commands - Seamless STT/TTS multimodal interaction",
108
- "• AI Dungeon Master Layer - Rules adjudication & encounter balancing",
109
- ],
88
+ lines: [],
110
89
  showDocumentDetails: true,
111
- link: { text: "Visit DiceMaster.io", url: "https://dicemaster.io/" },
112
- closing: "Experience the future of tabletop RPGs!",
90
+ link: null,
91
+ closing: null,
113
92
  },
114
93
  };
115
- const plain = {
116
- background: { image: null, color: "#fdfaf3" },
117
- colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
118
- brand: { name: null, url: null, tagline: null, linkLabel: null },
119
- metadata: { author: "", subject: "{title}", keywords: "D&D, lore", creator: "" },
120
- endPage: { enabled: false },
121
- };
94
+ // A built-in by name. "dicemaster" is still read, from projects saved while it was built in,
95
+ // and gives exactly the values it gave then.
122
96
  export function builtInTheme(name) {
123
- return name === "plain" ? resolveTheme(plain) : resolveTheme();
97
+ return resolveTheme({}, name === legacyDocumentTheme ? legacyDiceMasterTheme : plain);
98
+ }
99
+ // The theme a project's document is drawn with: its copy of a Library theme, or a built-in.
100
+ export function resolvedDocumentTheme(settings) {
101
+ const custom = settings?.custom;
102
+ return custom === undefined
103
+ ? builtInTheme(documentThemeOf(settings))
104
+ : resolveTheme(custom.values);
124
105
  }
125
106
  export function defaultTheme() {
126
107
  return builtInTheme(defaultDocumentTheme);
127
108
  }
128
109
  // Unknown sections and keys throw, so a typo in a saved theme can't pass silently.
129
- export function resolveTheme(overrides = {}, base = dicemaster) {
110
+ export function resolveTheme(overrides = {}, base = plain) {
130
111
  const theme = structuredClone(base);
131
112
  for (const [section, values] of Object.entries(overrides)) {
132
113
  const target = theme[section];
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { outputPath } from "../storage/layout.js";
3
+ import { parsePronunciationGlossary } from "./pronunciation.js";
4
+ export function collectSharedGlossary(deps, exceptProjectId) {
5
+ const rows = deps.db
6
+ .prepare(`SELECT o.project_id AS projectId, a.path AS path FROM revision_outputs o
7
+ JOIN project_heads h ON h.project_id=o.project_id AND h.revision_id=o.revision_id
8
+ JOIN project_assets a ON a.id=o.asset_id
9
+ WHERE o.slot='article:glossary' AND o.selected=1 AND o.state='ready' AND o.project_id<>?
10
+ ORDER BY o.created_at DESC`)
11
+ .all(exceptProjectId ?? "");
12
+ const merged = new Map();
13
+ const contributors = new Set();
14
+ for (const row of rows) {
15
+ const projectId = String(row.projectId);
16
+ let text;
17
+ try {
18
+ text = readFileSync(outputPath(deps.paths, projectId, String(row.path)), "utf8");
19
+ }
20
+ catch {
21
+ // A glossary file removed by hand or by a cleanup has nothing to share.
22
+ continue;
23
+ }
24
+ const parsed = parsePronunciationGlossary(text);
25
+ // A glossary that doesn't parse can't be trusted in someone else's narration either.
26
+ if (!parsed.ok)
27
+ continue;
28
+ for (const entry of parsed.entries) {
29
+ const key = glossaryKey(entry.term);
30
+ if (merged.has(key))
31
+ continue;
32
+ merged.set(key, { term: entry.term, ipa: [...entry.ipa] });
33
+ contributors.add(projectId);
34
+ }
35
+ }
36
+ return {
37
+ entries: [...merged.values()].sort((a, b) => a.term.localeCompare(b.term)),
38
+ projects: contributors.size,
39
+ };
40
+ }
41
+ // A project's own glossary comes first; a shared term it already defines is left out, so
42
+ // there is never a conflict for the parser to refuse.
43
+ export function withSharedGlossary(own, shared) {
44
+ if (shared === undefined || shared.length === 0)
45
+ return own;
46
+ const taken = new Set(own.map((entry) => glossaryKey(entry.term)));
47
+ return [...own, ...shared.filter((entry) => !taken.has(glossaryKey(entry.term)))];
48
+ }
49
+ function glossaryKey(term) {
50
+ return term.normalize("NFC").toLocaleLowerCase("en").replace(/\s+/gu, " ").trim();
51
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
3
3
  import { runDraftSchema } from "../admission/schema.js";
4
- import { documentThemeOf } from "../document/model.js";
4
+ import { draftDocumentThemeOf } from "../document/model.js";
5
5
  import { defaultSubtitles } from "../subtitles/model.js";
6
6
  export function toAdmissionDraft(input) {
7
7
  const { form } = input.document;
@@ -72,11 +72,20 @@ export function toAdmissionDraft(input) {
72
72
  format: form.format,
73
73
  sources,
74
74
  ...(sources.document === "generate"
75
- ? { document: { theme: documentThemeOf(form.document) } }
75
+ ? {
76
+ document: {
77
+ theme: draftDocumentThemeOf(form.document),
78
+ ...(form.document?.custom === undefined ? {} : { custom: form.document.custom }),
79
+ },
80
+ }
76
81
  : {}),
77
82
  llm: form.llm,
78
83
  audio: sources.audio === "generate" || form.audio.usePronunciationGlossary !== undefined
79
- ? form.audio
84
+ ? // With the glossary on, sharing it is on unless the draft turned it off, as Play
85
+ // shows it for a draft saved before the switch existed.
86
+ form.audio.usePronunciationGlossary === true && form.audio.shareGlossary === undefined
87
+ ? { ...form.audio, shareGlossary: true }
88
+ : form.audio
80
89
  : undefined,
81
90
  images: form.images,
82
91
  articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
@@ -5,7 +5,7 @@ import { motionStyles, stageSources } from "../admission/model.js";
5
5
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
6
6
  import { runDraftSchema } from "../admission/schema.js";
7
7
  import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
8
- import { documentThemes } from "../document/model.js";
8
+ import { documentSettingsSchema } from "../document/theme-schema.js";
9
9
  import { librarySnapshotSchema } from "../library/snapshot.js";
10
10
  import { chunkModes } from "../narration/chunk.js";
11
11
  import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
@@ -35,14 +35,14 @@ export const playDraftFormSchema = z
35
35
  .strict()
36
36
  .readonly(),
37
37
  // Absent until the theme is first chosen: the default theme.
38
- document: z
39
- .object({ theme: z.enum(documentThemes) })
40
- .strict()
41
- .readonly()
42
- .optional(),
38
+ document: documentSettingsSchema.readonly().optional(),
43
39
  llm: provider.readonly(),
44
40
  audio: provider
45
- .extend({ voice: text, usePronunciationGlossary: z.boolean().optional() })
41
+ .extend({
42
+ voice: text,
43
+ usePronunciationGlossary: z.boolean().optional(),
44
+ shareGlossary: z.boolean().optional(),
45
+ })
46
46
  .readonly(),
47
47
  images: provider.readonly(),
48
48
  articlePrompt: text,
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { transact } from "../../kernel/db/tx.js";
3
+ import { sourceOf } from "../admission/model.js";
3
4
  import { projectById } from "../admission/repo.js";
4
5
  import { detectSlots } from "../admission/substitute.js";
5
6
  import { listCheckpoints } from "../checkpoints/repo.js";
7
+ import { documentThemeOf } from "../document/model.js";
6
8
  import { requestHash } from "../play-drafts/repo.js";
7
9
  import { currentRevisionId, revisionById } from "../revisions/repo.js";
8
10
  import { createTemplate, readTemplate } from "./service.js";
@@ -128,7 +130,13 @@ function documentFromProject(deps, revision) {
128
130
  title: config.title,
129
131
  format: config.format,
130
132
  sources: config.sources,
131
- ...(config.document === undefined ? {} : { document: config.document }),
133
+ // A project from before theme settings was drawn with DiceMaster; the template says so
134
+ // outright, as a draft without a theme would now mean Plain.
135
+ ...(config.document === undefined
136
+ ? sourceOf(config.sources, "document") === "generate"
137
+ ? { document: { theme: documentThemeOf(undefined) } }
138
+ : {}
139
+ : { document: config.document }),
132
140
  checkpoints: listCheckpoints(deps.db, revision.projectId, revision.id).map((gate) => gate.stage),
133
141
  llm: config.llm ?? { provider: "", model: "" },
134
142
  audio: config.audio ?? { provider: "", model: "", voice: "" },
@@ -1,7 +1,7 @@
1
1
  import { stageKinds } from "../../kernel/pipeline.js";
2
2
  import { sourceOf } from "../admission/model.js";
3
3
  import { motionStyleLabels } from "../admission/rules.js";
4
- import { documentThemeLabels, documentThemeOf } from "../document/model.js";
4
+ import { documentThemeLabel } from "../document/model.js";
5
5
  import { getRevisionView } from "../revisions/view.js";
6
6
  export function previewDetails(deps, view, allRecipes, selected) {
7
7
  const parent = view.revision.parentId === null
@@ -137,7 +137,14 @@ function inputChanges(parent, view) {
137
137
  add("Intro", before.intro, after.intro);
138
138
  add("Outro", before.outro, after.outro);
139
139
  add("Subtitle settings", before.subtitles, after.subtitles);
140
- add("Document theme", documentThemeLabels[documentThemeOf(before.document)], documentThemeLabels[documentThemeOf(after.document)]);
140
+ const themeBefore = documentThemeLabel(before.document);
141
+ const themeAfter = documentThemeLabel(after.document);
142
+ // A Library theme saved again under the same name still changes the document.
143
+ add("Document theme", themeBefore, themeBefore === themeAfter &&
144
+ JSON.stringify(before.document?.custom?.values) !==
145
+ JSON.stringify(after.document?.custom?.values)
146
+ ? `${themeAfter} (updated settings)`
147
+ : themeAfter);
141
148
  add("Article text", parent.articleMarkdown, view.articleMarkdown);
142
149
  add("Provided research", before.provided.research, after.provided.research);
143
150
  for (const key of new Set([...Object.keys(before.values), ...Object.keys(after.values)]))