@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,749 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statfsSync } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { ZodError } from "zod";
7
+ import { migrate, newestMigration } from "../../kernel/db/migrate.js";
8
+ import { transact } from "../../kernel/db/tx.js";
9
+ import { projectById } from "../admission/repo.js";
10
+ import { documentThemeNameMax, documentThemeNameProblems } from "../document/model.js";
11
+ import { documentThemeSchema } from "../document/theme-schema.js";
12
+ import { nameMax } from "../library/model.js";
13
+ import { playDraftDocumentSchema } from "../play-drafts/schema.js";
14
+ import { projectTemplateSchema } from "../project-templates/schema.js";
15
+ import { revisionById } from "../revisions/repo.js";
16
+ import { runsForSchedule, scheduleRows } from "../schedules/repo.js";
17
+ import { allTelemetryEvents } from "../telemetry/repo.js";
18
+ import { projectRows, rowsOf } from "./backup-export.js";
19
+ import { backupMaxJsonBytes, backupMaxManifestBytes, backupSchemaVersion, checksumsMember, checksumsSchema, fontMember, libraryMember, libraryPartSchema, libraryTables, manifestHead, manifestMember, manifestSchema, projectFileMember, projectMember, projectPartSchema, projectTables, stagedMember, usageMember, usagePartSchema, usageTables, } from "./backup-format.js";
20
+ import { outputPath, projectDir, stagingPath } from "./layout.js";
21
+ import { checkLibraryRows, exportableSettings, installedUploadedFontFiles, validFontContent, } from "./portable.js";
22
+ import { readTar, TarFormatError } from "./tar.js";
23
+ // Importing a full backup adds to this install and never takes anything away:
24
+ //
25
+ // - a project whose id is already here (the same backup imported twice, or a backup of this
26
+ // very install) is skipped and reported, never overwritten or duplicated. Copying it under
27
+ // new ids would mean rewriting ids inside every revision, fingerprint and file name, and a
28
+ // copy nobody asked for is not worth that risk;
29
+ // - a prompt, intro/outro, document theme or template whose id is already here is skipped; one
30
+ // whose name is taken by a different item comes in as "<name> (imported)", and one
31
+ // identical to what is here is skipped;
32
+ // - settings only fill in what this install has not set;
33
+ // - schedules come in paused, so two installs never both run the same one;
34
+ // - usage history is added once per backup (its id is recorded), and never sent to
35
+ // slopify.stream again: the install that made it already counted it there;
36
+ // - provider keys and this install's telemetry id are never in a backup to begin with.
37
+ //
38
+ // Every JSON part is checked before any file is written. Files stream to a folder of their
39
+ // own under imports/, and are moved into place in the same synchronous step that commits the
40
+ // rows, so a failed import leaves the install as it was.
41
+ const importing = new WeakSet();
42
+ export class BackupImportRefused extends Error {
43
+ status;
44
+ constructor(message, status = 400) {
45
+ super(message);
46
+ this.status = status;
47
+ }
48
+ }
49
+ const chooseAgain = "Choose a .tar file made with Export everything in Settings → Backup & storage, or export it again.";
50
+ function damaged(why) {
51
+ return new BackupImportRefused(`This backup can't be imported: ${why} ${chooseAgain}`);
52
+ }
53
+ export async function importBackup(deps, source) {
54
+ if (importing.has(deps.db))
55
+ throw new BackupImportRefused("Another backup is still being imported. Wait for it to finish, then import this one.", 409);
56
+ importing.add(deps.db);
57
+ const imports = join(deps.paths.dataDir, "imports");
58
+ // A folder left by an import the app was stopped in the middle of holds nothing the
59
+ // database points at.
60
+ rmSync(imports, { recursive: true, force: true });
61
+ const work = join(imports, deps.ids.next());
62
+ mkdirSync(work, { recursive: true, mode: 0o700 });
63
+ let prepared;
64
+ try {
65
+ let manifest;
66
+ let library;
67
+ let usage;
68
+ const parts = [];
69
+ const expected = new Map();
70
+ const received = new Map();
71
+ let checksums;
72
+ const ready = () => {
73
+ if (manifest === undefined || library === undefined || usage === undefined)
74
+ return;
75
+ prepared = prepare(deps, work, manifest, library, usage, parts, expected);
76
+ };
77
+ await readTar(source, async (entry, body) => {
78
+ if (checksums !== undefined)
79
+ throw damaged("it has files after its checksum list.");
80
+ if (manifest === undefined) {
81
+ expectMember(entry.name, manifestMember);
82
+ manifest = readManifest(await readJson(body, entry.size, backupMaxManifestBytes));
83
+ return;
84
+ }
85
+ if (library === undefined) {
86
+ expectMember(entry.name, libraryMember);
87
+ library = part(libraryPartSchema, await readJson(body, entry.size), "library");
88
+ return;
89
+ }
90
+ if (usage === undefined) {
91
+ expectMember(entry.name, usageMember);
92
+ usage = part(usagePartSchema, await readJson(body, entry.size), "usage history");
93
+ if (manifest.projects.length === 0)
94
+ ready();
95
+ return;
96
+ }
97
+ const next = manifest.projects[parts.length];
98
+ if (next !== undefined) {
99
+ expectMember(entry.name, projectMember(next.id));
100
+ const project = part(projectPartSchema, await readJson(body, entry.size), next.title);
101
+ if (project.id !== next.id)
102
+ throw damaged(`the part for "${next.title}" is another project's.`);
103
+ parts.push(project);
104
+ if (parts.length === manifest.projects.length)
105
+ ready();
106
+ return;
107
+ }
108
+ if (entry.name === checksumsMember) {
109
+ const list = part(checksumsSchema, await readJson(body, entry.size), "checksum list");
110
+ checksums = new Map(list.files.map((file) => [file.member, file.sha256]));
111
+ return;
112
+ }
113
+ const file = expected.get(entry.name);
114
+ if (file === undefined || received.has(entry.name))
115
+ throw damaged(`it holds a file its contents list doesn't name (${entry.name}).`);
116
+ if (entry.size !== file.bytes)
117
+ throw damaged(`${entry.name} is not the size its list says.`);
118
+ received.set(entry.name, await receive(body, file));
119
+ });
120
+ if (manifest === undefined || prepared === undefined || checksums === undefined)
121
+ throw damaged("the file ends before the backup does; it was probably cut short while downloading.");
122
+ for (const name of expected.keys()) {
123
+ const digest = received.get(name);
124
+ if (digest === undefined)
125
+ throw damaged(`${name} is missing from it.`);
126
+ if (checksums.get(name) !== digest)
127
+ throw damaged(`${name} doesn't match the checksum it was saved with; the file is damaged.`);
128
+ }
129
+ if (checksums.size !== expected.size)
130
+ throw damaged("its checksum list names files it doesn't hold.");
131
+ return commit(deps, work, manifest, prepared);
132
+ }
133
+ catch (error) {
134
+ throw asRefusal(error);
135
+ }
136
+ finally {
137
+ prepared?.scratch.close();
138
+ rmSync(work, { recursive: true, force: true });
139
+ importing.delete(deps.db);
140
+ }
141
+ }
142
+ function asRefusal(error) {
143
+ if (error instanceof BackupImportRefused)
144
+ return error;
145
+ if (error instanceof TarFormatError)
146
+ return damaged(`it is not a complete archive (${error.message})`);
147
+ if (error instanceof ZodError)
148
+ return damaged("it has a record Slopify can't read.");
149
+ return error;
150
+ }
151
+ function expectMember(actual, wanted) {
152
+ if (actual !== wanted)
153
+ throw damaged(`its parts are out of order (found ${actual} where ${wanted} belongs).`);
154
+ }
155
+ async function readJson(body, size, max = backupMaxJsonBytes) {
156
+ if (size > max)
157
+ throw damaged("one of its parts is larger than any backup Slopify writes.");
158
+ const chunks = [];
159
+ for await (const chunk of body.chunks())
160
+ chunks.push(chunk);
161
+ try {
162
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
163
+ }
164
+ catch {
165
+ throw damaged("one of its parts is not readable JSON.");
166
+ }
167
+ }
168
+ function readManifest(value) {
169
+ const head = manifestHead.safeParse(value);
170
+ if (!head.success)
171
+ throw damaged("it has no Slopify backup manifest.");
172
+ const newer = new BackupImportRefused(`This backup was made by a newer Slopify (${head.data.appVersion}). Update Slopify first (npx @gentbajko/slopify@latest, or pull the latest Docker image), then import it again.`, 422);
173
+ if (head.data.schemaVersion > backupSchemaVersion)
174
+ throw newer;
175
+ const manifest = part(manifestSchema, value, "manifest");
176
+ if (manifest.databaseVersion > newestMigration())
177
+ throw newer;
178
+ const ids = new Set(manifest.projects.map((project) => project.id));
179
+ if (ids.size !== manifest.projects.length)
180
+ throw damaged("it lists a project twice.");
181
+ return manifest;
182
+ }
183
+ function part(schema, value, name) {
184
+ const parsed = schema.safeParse(value);
185
+ if (!parsed.success)
186
+ throw damaged(`its ${name} part has a record Slopify can't read.`);
187
+ return parsed.data;
188
+ }
189
+ async function receive(body, file) {
190
+ const hash = createHash("sha256");
191
+ if (file.font !== undefined) {
192
+ const chunks = [];
193
+ for await (const chunk of body.chunks()) {
194
+ hash.update(chunk);
195
+ chunks.push(chunk);
196
+ }
197
+ const content = Buffer.concat(chunks);
198
+ if (!validFontContent(file.font.id, file.font.extension, content))
199
+ throw damaged(`the uploaded font ${file.font.id} in it is damaged.`);
200
+ if (file.destination !== undefined) {
201
+ mkdirSync(dirname(file.destination), { recursive: true, mode: 0o700 });
202
+ const handle = await open(file.destination, "wx", 0o600);
203
+ try {
204
+ await handle.write(content);
205
+ }
206
+ finally {
207
+ await handle.close();
208
+ }
209
+ }
210
+ return hash.digest("hex");
211
+ }
212
+ if (file.destination === undefined) {
213
+ for await (const chunk of body.chunks())
214
+ hash.update(chunk);
215
+ return hash.digest("hex");
216
+ }
217
+ mkdirSync(dirname(file.destination), { recursive: true, mode: 0o700 });
218
+ const handle = await open(file.destination, "wx", 0o600);
219
+ try {
220
+ for await (const chunk of body.chunks()) {
221
+ hash.update(chunk);
222
+ await handle.write(chunk);
223
+ }
224
+ }
225
+ finally {
226
+ await handle.close();
227
+ }
228
+ return hash.digest("hex");
229
+ }
230
+ // Loads every row into a scratch database at the schema the backup was written with, carries
231
+ // it forward with this build's migrations, and checks it with the same readers the app uses.
232
+ // Then decides which projects and drafts come in, and where each kept file waits.
233
+ function prepare(deps, work, manifest, library, usage, parts, expected) {
234
+ const scratch = loadScratch(deps.clock, manifest, library, usage, parts);
235
+ try {
236
+ checkScratch(deps, scratch, library, parts);
237
+ const projects = new Set();
238
+ const skippedProjects = new Map();
239
+ for (const project of parts) {
240
+ const conflict = projectConflict(deps, scratch, project.id);
241
+ if (conflict === undefined)
242
+ projects.add(project.id);
243
+ else
244
+ skippedProjects.set(project.id, conflict);
245
+ }
246
+ const drafts = new Set(rowsOf(scratch, "SELECT id FROM play_drafts")
247
+ .map((row) => String(row.id))
248
+ .filter((id) => draftConflict(deps, scratch, id) === undefined));
249
+ const keptStaged = new Set(rowsOf(scratch, "SELECT staged_file_id AS id FROM play_draft_attachments WHERE staged_file_id IS NOT NULL")
250
+ .filter((row) => drafts.has(draftOfStaged(scratch, String(row.id))))
251
+ .map((row) => String(row.id)));
252
+ const expect = (member, value) => {
253
+ if (expected.has(member))
254
+ throw damaged(`it lists ${member} twice.`);
255
+ expected.set(member, value);
256
+ };
257
+ let needed = 0;
258
+ for (const project of parts) {
259
+ const keep = projects.has(project.id);
260
+ if (keep)
261
+ mkdirSync(join(work, "projects", project.id), { recursive: true, mode: 0o700 });
262
+ for (const file of project.files) {
263
+ expect(projectFileMember(project.id, file.path), {
264
+ bytes: file.bytes,
265
+ destination: keep ? join(work, "projects", project.id, file.path) : undefined,
266
+ });
267
+ if (keep)
268
+ needed += file.bytes;
269
+ }
270
+ }
271
+ const installed = new Set(installedUploadedFontFiles(deps.paths).map((font) => font.name));
272
+ for (const font of library.fonts) {
273
+ const match = /^(uploaded-[a-f0-9]{64})(\.ttf|\.otf)$/.exec(font.name);
274
+ const id = match?.[1];
275
+ const extension = match?.[2];
276
+ if (id === undefined || (extension !== ".ttf" && extension !== ".otf"))
277
+ throw damaged("it names an uploaded font wrongly.");
278
+ const keep = !installed.has(font.name) && !existsSync(join(deps.paths.dataDir, "fonts", font.name));
279
+ expect(fontMember(font.name), {
280
+ bytes: font.bytes,
281
+ destination: keep ? join(work, "fonts", font.name) : undefined,
282
+ font: { id, extension },
283
+ });
284
+ }
285
+ for (const staged of library.staged) {
286
+ const keep = keptStaged.has(staged.id);
287
+ expect(stagedMember(staged.id), {
288
+ bytes: staged.bytes,
289
+ destination: keep ? join(work, "staging", staged.id) : undefined,
290
+ });
291
+ if (keep)
292
+ needed += staged.bytes;
293
+ }
294
+ checkFreeSpace(deps.paths.dataDir, needed);
295
+ return { scratch, projects, skippedProjects, drafts };
296
+ }
297
+ catch (error) {
298
+ scratch.close();
299
+ throw error;
300
+ }
301
+ }
302
+ function loadScratch(clock, manifest, library, usage, parts) {
303
+ const scratch = new DatabaseSync(":memory:");
304
+ try {
305
+ migrate(scratch, clock, { through: manifest.databaseVersion });
306
+ // Rows arrive table by table, so a child may come before its parent; every link is
307
+ // checked once they are all in.
308
+ scratch.exec("PRAGMA foreign_keys = OFF");
309
+ const columns = new Map();
310
+ const insert = (table, row) => {
311
+ let known = columns.get(table);
312
+ if (known === undefined) {
313
+ known = new Set(scratch
314
+ .prepare("SELECT name FROM pragma_table_info(?)")
315
+ .all(table)
316
+ .map((column) => String(column.name)));
317
+ columns.set(table, known);
318
+ }
319
+ if (known.size === 0 || Object.keys(row).some((column) => !known.has(column)))
320
+ throw damaged(`it has ${table} records that don't fit the Slopify version it names.`);
321
+ try {
322
+ insertRow(scratch, table, row);
323
+ }
324
+ catch {
325
+ throw damaged(`it has a ${table} record Slopify can't read.`);
326
+ }
327
+ };
328
+ for (const table of libraryTables)
329
+ for (const row of library.tables[table] ?? [])
330
+ insert(table, row);
331
+ for (const table of usageTables)
332
+ for (const row of usage.tables[table] ?? [])
333
+ if (row.type !== "install")
334
+ insert(table, row);
335
+ for (const project of parts) {
336
+ const stages = new Set((project.tables.stages ?? []).map((row) => row.id));
337
+ const work = new Set((project.tables.revision_work ?? []).map((row) => row.id));
338
+ for (const table of projectTables)
339
+ for (const row of project.tables[table] ?? []) {
340
+ const owned = table === "projects"
341
+ ? row.id === project.id
342
+ : table === "attempts" || table === "stage_pieces"
343
+ ? stages.has(row.stage_id ?? null)
344
+ : table === "revision_work_pieces"
345
+ ? work.has(row.work_id ?? null)
346
+ : row.project_id === project.id;
347
+ if (!owned)
348
+ throw damaged(`the part for one project holds another project's records.`);
349
+ insert(table, row);
350
+ }
351
+ }
352
+ try {
353
+ migrate(scratch, clock);
354
+ }
355
+ catch {
356
+ // A table rebuild checks every link before it commits; a backup whose links are
357
+ // broken fails there, and the upgrade's own message would talk about downgrading.
358
+ throw damaged("some of its records point at records it doesn't hold.");
359
+ }
360
+ if (scratch.prepare("PRAGMA foreign_key_check").all().length > 0)
361
+ throw damaged("some of its records point at records it doesn't hold.");
362
+ return scratch;
363
+ }
364
+ catch (error) {
365
+ scratch.close();
366
+ throw error;
367
+ }
368
+ }
369
+ // The same readers the screens use, so nothing comes in that would break a list later.
370
+ function checkScratch(deps, scratch, library, parts) {
371
+ const unreadable = (what) => damaged(`it has ${what} Slopify can't read.`);
372
+ try {
373
+ checkLibraryRows({
374
+ prompts: rowsOf(scratch, "SELECT * FROM prompts"),
375
+ entries: rowsOf(scratch, "SELECT * FROM entries"),
376
+ voices: rowsOf(scratch, "SELECT * FROM voices"),
377
+ });
378
+ }
379
+ catch {
380
+ throw unreadable("a prompt, intro, outro or voice");
381
+ }
382
+ try {
383
+ for (const row of rowsOf(scratch, "SELECT t.id AS id,r.version AS version,r.name AS name,t.created_at AS createdAt,r.created_at AS updatedAt,r.document_json AS document FROM project_template_revisions r JOIN project_templates t ON t.id=r.template_id"))
384
+ projectTemplateSchema.parse({ ...row, document: JSON.parse(String(row.document)) });
385
+ const headless = scratch
386
+ .prepare("SELECT 1 FROM project_templates t WHERE NOT EXISTS (SELECT 1 FROM project_template_revisions r WHERE r.template_id=t.id AND r.version=t.head_version)")
387
+ .get();
388
+ if (headless !== undefined)
389
+ throw new Error("template without its current version");
390
+ }
391
+ catch {
392
+ throw unreadable("a project template");
393
+ }
394
+ try {
395
+ for (const row of rowsOf(scratch, "SELECT name,values_json FROM document_themes")) {
396
+ documentThemeSchema.parse(JSON.parse(String(row.values_json)));
397
+ if (documentThemeNameProblems(String(row.name)).length > 0)
398
+ throw new Error("name");
399
+ }
400
+ }
401
+ catch {
402
+ throw unreadable("a document theme");
403
+ }
404
+ try {
405
+ for (const schedule of scheduleRows(scratch))
406
+ runsForSchedule(scratch, schedule.id);
407
+ }
408
+ catch {
409
+ throw unreadable("a schedule");
410
+ }
411
+ try {
412
+ for (const row of rowsOf(scratch, "SELECT document_json FROM play_drafts"))
413
+ playDraftDocumentSchema.parse(JSON.parse(String(row.document_json)));
414
+ const staged = new Map(library.staged.map((file) => [file.id, file.bytes]));
415
+ const rows = rowsOf(scratch, "SELECT id,path,bytes,state FROM staged_files");
416
+ if (rows.length !== staged.size ||
417
+ rows.some((row) => row.path !== row.id || row.state !== "staged" || staged.get(String(row.id)) !== row.bytes))
418
+ throw new Error("staged files");
419
+ }
420
+ catch {
421
+ throw unreadable("a Play draft");
422
+ }
423
+ try {
424
+ allTelemetryEvents(scratch);
425
+ }
426
+ catch {
427
+ throw unreadable("usage history");
428
+ }
429
+ for (const project of parts) {
430
+ try {
431
+ if (projectById(scratch, project.id) === undefined)
432
+ throw new Error("project");
433
+ for (const row of rowsOf(scratch, "SELECT id FROM project_revisions WHERE project_id=?", project.id))
434
+ revisionById(scratch, project.id, String(row.id));
435
+ for (const row of rowsOf(scratch, "SELECT path FROM outputs WHERE project_id=? UNION ALL SELECT path FROM project_assets WHERE project_id=?", project.id, project.id))
436
+ outputPath(deps.paths, project.id, String(row.path));
437
+ }
438
+ catch {
439
+ const title = rowsOf(scratch, "SELECT title FROM projects WHERE id=?", project.id)[0]?.title;
440
+ throw unreadable(`a project ("${String(title ?? project.id)}")`);
441
+ }
442
+ }
443
+ }
444
+ function projectConflict(deps, scratch, id) {
445
+ if (deps.db.prepare("SELECT 1 FROM projects WHERE id=?").get(id) !== undefined)
446
+ return "It is already in this install.";
447
+ if (existsSync(projectDir(deps.paths, id)))
448
+ return "Its folder is already in the projects folder without a project. Press Clean orphan files in Settings → Backup & storage, then import again.";
449
+ for (const table of projectTables)
450
+ for (const row of projectRows(scratch, table, id))
451
+ if (typeof row.id === "string" &&
452
+ deps.db.prepare(`SELECT 1 FROM ${table} WHERE id=?`).get(row.id) !== undefined)
453
+ return "Some of its records have the same ids as records already in this install.";
454
+ return undefined;
455
+ }
456
+ function draftConflict(deps, scratch, id) {
457
+ if (deps.db.prepare("SELECT 1 FROM play_drafts WHERE id=?").get(id) !== undefined)
458
+ return "already here";
459
+ for (const row of rowsOf(scratch, "SELECT id,staged_file_id FROM play_draft_attachments WHERE draft_id=?", id)) {
460
+ if (deps.db.prepare("SELECT 1 FROM play_draft_attachments WHERE id=?").get(String(row.id)))
461
+ return "clash";
462
+ const staged = row.staged_file_id;
463
+ if (typeof staged === "string" &&
464
+ (deps.db.prepare("SELECT 1 FROM staged_files WHERE id=?").get(staged) !== undefined ||
465
+ existsSync(stagingPath(deps.paths, staged))))
466
+ return "clash";
467
+ }
468
+ return undefined;
469
+ }
470
+ function draftOfStaged(scratch, stagedId) {
471
+ return String(scratch
472
+ .prepare("SELECT draft_id FROM play_draft_attachments WHERE staged_file_id=?")
473
+ .get(stagedId)?.draft_id);
474
+ }
475
+ function checkFreeSpace(dataDir, needed) {
476
+ const stats = statfsSync(dataDir);
477
+ const free = Number(stats.bavail) * Number(stats.bsize);
478
+ // Headroom for the database growing by the rows that come with the files.
479
+ if (needed + 64 * 1024 * 1024 > free)
480
+ throw new BackupImportRefused(`There isn't enough free space in the Slopify data folder (${dataDir}): this backup needs ${gigabytes(needed)} and ${gigabytes(free)} is free. Free up space on that disk, then import it again.`, 409);
481
+ }
482
+ function gigabytes(bytes) {
483
+ return `${(bytes / 1024 ** 3).toFixed(bytes < 1024 ** 3 ? 2 : 1)} GB`;
484
+ }
485
+ // Everything this install gains, in one transaction; the files waiting under imports/ are
486
+ // moved into place inside it, and moved back if it does not commit.
487
+ function commit(deps, work, manifest, prepared) {
488
+ const { db, paths } = deps;
489
+ const scratch = prepared.scratch;
490
+ const now = deps.clock.now().toISOString();
491
+ const moved = [];
492
+ const move = (from, to) => {
493
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
494
+ if (existsSync(to))
495
+ throw new Error(`${to} appeared during the import.`);
496
+ renameSync(from, to);
497
+ moved.push([from, to]);
498
+ };
499
+ try {
500
+ return transact(db, () => {
501
+ // Rows go in table by table; links are checked when the transaction commits.
502
+ db.exec("PRAGMA defer_foreign_keys = ON");
503
+ const has = (sql, ...params) => db.prepare(sql).get(...params) !== undefined;
504
+ let settingsAdded = 0;
505
+ let settingsKept = 0;
506
+ const stored = {};
507
+ for (const row of rowsOf(scratch, "SELECT key,value FROM settings"))
508
+ stored[String(row.key)] = String(row.value);
509
+ for (const [key, value] of Object.entries(exportableSettings(stored))) {
510
+ if (has("SELECT 1 FROM settings WHERE key=?", key))
511
+ settingsKept += 1;
512
+ else {
513
+ insertRow(db, "settings", { key, value });
514
+ settingsAdded += 1;
515
+ }
516
+ }
517
+ const prompts = mergeNamed(db, scratch, {
518
+ table: "prompts",
519
+ sameName: "SELECT body FROM prompts WHERE kind=? AND lower(name)=lower(?)",
520
+ key: (row) => [row.kind ?? null],
521
+ same: (row, existing) => existing.body === row.body,
522
+ max: nameMax,
523
+ });
524
+ const entries = mergeNamed(db, scratch, {
525
+ table: "entries",
526
+ sameName: "SELECT mode,body FROM entries WHERE category=? AND lower(name)=lower(?)",
527
+ key: (row) => [row.category ?? null],
528
+ same: (row, existing) => existing.mode === row.mode && existing.body === row.body,
529
+ max: nameMax,
530
+ });
531
+ const documentThemes = mergeNamed(db, scratch, {
532
+ table: "document_themes",
533
+ sameName: "SELECT values_json FROM document_themes WHERE lower(name)=lower(?)",
534
+ key: () => [],
535
+ same: (row, existing) => existing.values_json === row.values_json,
536
+ max: documentThemeNameMax,
537
+ });
538
+ let voicesAdded = 0;
539
+ let voicesSkipped = 0;
540
+ for (const row of rowsOf(scratch, "SELECT * FROM voices ORDER BY rowid")) {
541
+ if (has("SELECT 1 FROM voices WHERE id=?", String(row.id)) ||
542
+ has("SELECT 1 FROM voices WHERE provider=? AND voice_id=?", String(row.provider), String(row.voice_id)))
543
+ voicesSkipped += 1;
544
+ else {
545
+ insertRow(db, "voices", row);
546
+ voicesAdded += 1;
547
+ }
548
+ }
549
+ const templates = { added: 0, renamed: 0, skipped: 0 };
550
+ for (const template of rowsOf(scratch, "SELECT * FROM project_templates ORDER BY rowid")) {
551
+ const id = String(template.id);
552
+ if (has("SELECT 1 FROM project_templates WHERE id=?", id)) {
553
+ templates.skipped += 1;
554
+ continue;
555
+ }
556
+ insertRow(db, "project_templates", template);
557
+ let renamed = false;
558
+ for (const revision of rowsOf(scratch, "SELECT * FROM project_template_revisions WHERE template_id=? ORDER BY version", id)) {
559
+ let next = revision;
560
+ if (revision.version === template.head_version) {
561
+ const name = String(revision.name);
562
+ const taken = (candidate) => has("SELECT 1 FROM project_templates t JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version WHERE t.id<>? AND lower(r.name)=lower(?)", id, candidate);
563
+ if (taken(name)) {
564
+ next = { ...revision, name: importedName(name, 200, taken) };
565
+ renamed = true;
566
+ }
567
+ }
568
+ insertRow(db, "project_template_revisions", next);
569
+ }
570
+ if (renamed)
571
+ templates.renamed += 1;
572
+ else
573
+ templates.added += 1;
574
+ }
575
+ const schedules = { added: 0, renamed: 0, skipped: 0, paused: 0 };
576
+ for (const schedule of rowsOf(scratch, "SELECT * FROM schedules ORDER BY rowid")) {
577
+ if (has("SELECT 1 FROM schedules WHERE id=?", String(schedule.id)) ||
578
+ !has("SELECT 1 FROM project_template_revisions WHERE template_id=? AND version=?", String(schedule.template_id), Number(schedule.template_version))) {
579
+ schedules.skipped += 1;
580
+ continue;
581
+ }
582
+ // Two installs running one schedule would make every project twice. It comes in
583
+ // paused; Resume on the Schedules screen starts it here.
584
+ const active = schedule.status === "active";
585
+ insertRow(db, "schedules", active ? { ...schedule, status: "paused", updated_at: now } : schedule);
586
+ if (active)
587
+ schedules.paused += 1;
588
+ for (const run of rowsOf(scratch, "SELECT * FROM schedule_runs WHERE schedule_id=? ORDER BY rowid", String(schedule.id)))
589
+ if (!has("SELECT 1 FROM schedule_runs WHERE id=?", String(run.id)))
590
+ insertRow(db, "schedule_runs", run);
591
+ schedules.added += 1;
592
+ }
593
+ const drafts = { added: 0, renamed: 0, skipped: 0 };
594
+ for (const draft of rowsOf(scratch, "SELECT * FROM play_drafts ORDER BY rowid")) {
595
+ const id = String(draft.id);
596
+ if (!prepared.drafts.has(id) || draftConflict(deps, scratch, id) !== undefined) {
597
+ drafts.skipped += 1;
598
+ continue;
599
+ }
600
+ insertRow(db, "play_drafts", draft);
601
+ for (const attachment of rowsOf(scratch, "SELECT * FROM play_draft_attachments WHERE draft_id=? ORDER BY rowid", id)) {
602
+ const staged = attachment.staged_file_id;
603
+ if (typeof staged === "string" && !has("SELECT 1 FROM staged_files WHERE id=?", staged)) {
604
+ const row = rowsOf(scratch, "SELECT * FROM staged_files WHERE id=?", staged)[0];
605
+ if (row === undefined)
606
+ throw new Error("staged file row missing");
607
+ insertRow(db, "staged_files", { ...row, created_at: now });
608
+ move(join(work, "staging", staged), stagingPath(paths, staged));
609
+ }
610
+ insertRow(db, "play_draft_attachments", attachment);
611
+ }
612
+ for (const row of rowsOf(scratch, "SELECT * FROM project_template_instantiations WHERE draft_id=?", id))
613
+ insertRow(db, "project_template_instantiations", row);
614
+ drafts.added += 1;
615
+ }
616
+ let fonts = 0;
617
+ const fontDir = join(work, "fonts");
618
+ if (existsSync(fontDir))
619
+ for (const name of readdirSync(fontDir)) {
620
+ const target = join(paths.dataDir, "fonts", name);
621
+ if (existsSync(target))
622
+ continue;
623
+ move(join(fontDir, name), target);
624
+ fonts += 1;
625
+ }
626
+ const alreadyImported = has("SELECT 1 FROM backup_imports WHERE backup_id=?", manifest.backupId);
627
+ let events = 0;
628
+ if (!alreadyImported)
629
+ for (const event of rowsOf(scratch, "SELECT * FROM telemetry_events ORDER BY rowid")) {
630
+ // Marked delivered: the install that recorded it already counted it (or will).
631
+ const changes = insertRow(db, "telemetry_events", { ...event, delivered_at: event.delivered_at ?? now }, "INSERT OR IGNORE");
632
+ events += changes;
633
+ }
634
+ const imported = [];
635
+ const skipped = [];
636
+ let count = 0;
637
+ let bytes = 0;
638
+ for (const listed of manifest.projects) {
639
+ const reason = prepared.skippedProjects.get(listed.id) ??
640
+ (prepared.projects.has(listed.id)
641
+ ? projectConflict(deps, scratch, listed.id)
642
+ : undefined);
643
+ if (reason !== undefined) {
644
+ skipped.push({ id: listed.id, title: listed.title, reason });
645
+ continue;
646
+ }
647
+ for (const table of projectTables)
648
+ for (const row of projectRows(scratch, table, listed.id))
649
+ insertRow(db, table, row);
650
+ const folder = join(work, "projects", listed.id);
651
+ for (const entry of readdirSync(folder, { withFileTypes: true, recursive: true }))
652
+ if (entry.isFile())
653
+ count += 1;
654
+ bytes += listed.bytes;
655
+ move(folder, projectDir(paths, listed.id));
656
+ imported.push({ id: listed.id, title: listed.title });
657
+ }
658
+ const summary = {
659
+ backup: {
660
+ id: manifest.backupId,
661
+ createdAt: manifest.createdAt,
662
+ appVersion: manifest.appVersion,
663
+ },
664
+ projects: { imported, skipped },
665
+ prompts,
666
+ entries,
667
+ documentThemes,
668
+ templates,
669
+ schedules,
670
+ voices: { added: voicesAdded, renamed: 0, skipped: voicesSkipped },
671
+ drafts,
672
+ settings: { added: settingsAdded, kept: settingsKept },
673
+ fonts,
674
+ usage: { events, alreadyImported },
675
+ files: { count, bytes },
676
+ };
677
+ db.prepare("INSERT INTO backup_imports(backup_id,imported_at,summary_json) VALUES(?,?,?) ON CONFLICT(backup_id) DO NOTHING").run(manifest.backupId, now, JSON.stringify(summary));
678
+ return summary;
679
+ });
680
+ }
681
+ catch (error) {
682
+ for (const [from, to] of moved.toReversed()) {
683
+ try {
684
+ renameSync(to, from);
685
+ }
686
+ catch {
687
+ // Best effort: the folder under imports/ is removed next, and reconcile removes a
688
+ // project folder whose rows did not commit.
689
+ }
690
+ }
691
+ throw error;
692
+ }
693
+ }
694
+ // Library items whose name is unique per kind: same id is the same item and is skipped; the
695
+ // same name with the same content is skipped; the same name with other content comes in as
696
+ // "<name> (imported)".
697
+ function mergeNamed(db, scratch, rule) {
698
+ let added = 0;
699
+ let renamed = 0;
700
+ let skipped = 0;
701
+ for (const row of rowsOf(scratch, `SELECT * FROM ${rule.table} ORDER BY rowid`)) {
702
+ if (db.prepare(`SELECT 1 FROM ${rule.table} WHERE id=?`).get(String(row.id)) !== undefined) {
703
+ skipped += 1;
704
+ continue;
705
+ }
706
+ const name = String(row.name);
707
+ const lookup = (candidate) => db.prepare(rule.sameName).get(...rule.key(row), candidate);
708
+ const existing = lookup(name);
709
+ if (existing === undefined) {
710
+ insertRow(db, rule.table, row);
711
+ added += 1;
712
+ }
713
+ else if (rule.same(row, existing)) {
714
+ skipped += 1;
715
+ }
716
+ else {
717
+ insertRow(db, rule.table, {
718
+ ...row,
719
+ name: importedName(name, rule.max, (candidate) => lookup(candidate) !== undefined),
720
+ });
721
+ renamed += 1;
722
+ }
723
+ }
724
+ return { added, renamed, skipped };
725
+ }
726
+ export function importedName(name, max, taken) {
727
+ for (let n = 1;; n += 1) {
728
+ const suffix = n === 1 ? " (imported)" : ` (imported ${n})`;
729
+ const candidate = `${name.slice(0, Math.max(1, max - suffix.length)).trimEnd()}${suffix}`;
730
+ if (!taken(candidate))
731
+ return candidate;
732
+ }
733
+ }
734
+ const statements = new WeakMap();
735
+ function insertRow(db, table, row, verb = "INSERT") {
736
+ const columns = Object.keys(row);
737
+ const sql = `${verb} INTO ${table} (${columns.join(",")}) VALUES (${columns.map(() => "?").join(",")})`;
738
+ let cache = statements.get(db);
739
+ if (cache === undefined) {
740
+ cache = new Map();
741
+ statements.set(db, cache);
742
+ }
743
+ let statement = cache.get(sql);
744
+ if (statement === undefined) {
745
+ statement = db.prepare(sql);
746
+ cache.set(sql, statement);
747
+ }
748
+ return Number(statement.run(...columns.map((column) => row[column] ?? null)).changes);
749
+ }