@gentbajko/slopify 0.8.3 → 1.0.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/README.md +28 -6
- package/SUBTITLES.md +2 -0
- package/dist/adapters/alignment/window.js +46 -5
- package/dist/adapters/alignment/worker.js +2 -2
- package/dist/adapters/llm/claude-code.js +6 -3
- package/dist/adapters/llm/codex.js +102 -12
- package/dist/adapters/llm/gemini.js +11 -8
- package/dist/adapters/llm/run-cli.js +61 -6
- package/dist/adapters/tts/inworld-async.js +4 -4
- package/dist/adapters/tts/inworld.js +73 -68
- package/dist/catalog/registry.js +13 -73
- package/dist/edge/cli.js +5 -2
- package/dist/edge/events/hub.js +7 -2
- package/dist/edge/events/preview-cache.js +7 -4
- package/dist/edge/events/visibility.js +33 -0
- package/dist/edge/http/actions.js +74 -79
- package/dist/edge/http/app.js +39 -6
- package/dist/edge/http/audio-preview.js +9 -1
- package/dist/edge/http/checkpoints.js +95 -0
- package/dist/edge/http/diagnostics.js +31 -0
- package/dist/edge/http/draft-files.js +69 -0
- package/dist/edge/http/draft-problem.js +15 -0
- package/dist/edge/http/drafts.js +95 -0
- package/dist/edge/http/multipart.js +86 -0
- package/dist/edge/http/mutations.js +49 -0
- package/dist/edge/http/planning.js +12 -1
- package/dist/edge/http/project-templates.js +74 -0
- package/dist/edge/http/projects.js +19 -2
- package/dist/edge/http/revision-files.js +84 -0
- package/dist/edge/http/revisions.js +103 -0
- package/dist/edge/http/schedules.js +100 -0
- package/dist/edge/http/staging.js +11 -52
- package/dist/edge/http/storage.js +110 -0
- package/dist/edge/http/subtitles.js +9 -79
- package/dist/edge/http/tutorial.js +26 -0
- package/dist/edge/signal-shutdown.js +23 -0
- package/dist/kernel/audio-preview.js +11 -5
- package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
- package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
- package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
- package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
- package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
- package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
- package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
- package/dist/kernel/ports/model.js +5 -0
- package/dist/kernel/runner/attempt-repo.js +15 -0
- package/dist/kernel/runner/attempt.js +14 -2
- package/dist/kernel/runner/checkpoint-authority.js +23 -0
- package/dist/kernel/runner/index.js +35 -11
- package/dist/kernel/runner/providers.js +18 -8
- package/dist/kernel/runner/work-attempt.js +47 -0
- package/dist/kernel/runner/work-authority.js +37 -0
- package/dist/kernel/runner/work.js +23 -0
- package/dist/main.js +227 -69
- package/dist/slices/admission/repo.js +5 -56
- package/dist/slices/admission/schema.js +64 -0
- package/dist/slices/admission/start.js +13 -4
- package/dist/slices/article/continuation.js +9 -3
- package/dist/slices/article/provided-entries.js +7 -3
- package/dist/slices/article/run.js +20 -7
- package/dist/slices/article/segments.js +13 -4
- package/dist/slices/batch/index.js +8 -11
- package/dist/slices/cancel/index.js +78 -34
- package/dist/slices/checkpoints/change.js +250 -0
- package/dist/slices/checkpoints/fingerprint.js +33 -0
- package/dist/slices/checkpoints/index.js +2 -0
- package/dist/slices/checkpoints/model.js +1 -0
- package/dist/slices/checkpoints/recovery.js +116 -0
- package/dist/slices/checkpoints/repo.js +132 -0
- package/dist/slices/checkpoints/rules.js +30 -0
- package/dist/slices/checkpoints/schema.js +59 -0
- package/dist/slices/control/index.js +88 -22
- package/dist/slices/control/providers.js +3 -2
- package/dist/slices/control/revision-control-schema.js +7 -0
- package/dist/slices/control/revision-control.js +59 -0
- package/dist/slices/estimate/index.js +77 -59
- package/dist/slices/estimate/requests.js +102 -0
- package/dist/slices/images/run.js +14 -3
- package/dist/slices/library/slots.js +11 -11
- package/dist/slices/library/snapshot.js +33 -0
- package/dist/slices/narration/legacy-plan.js +58 -0
- package/dist/slices/narration/live.js +2 -2
- package/dist/slices/narration/plan.js +48 -54
- package/dist/slices/narration/run.js +21 -8
- package/dist/slices/play-drafts/convert.js +120 -0
- package/dist/slices/play-drafts/model.js +1 -0
- package/dist/slices/play-drafts/readiness.js +80 -0
- package/dist/slices/play-drafts/repo.js +76 -0
- package/dist/slices/play-drafts/review-inputs.js +150 -0
- package/dist/slices/play-drafts/review.js +109 -0
- package/dist/slices/play-drafts/schema.js +218 -0
- package/dist/slices/play-drafts/service.js +242 -0
- package/dist/slices/play-drafts/start-repo.js +113 -0
- package/dist/slices/play-drafts/start.js +148 -0
- package/dist/slices/play-drafts/uploads.js +67 -0
- package/dist/slices/project-templates/from-project.js +159 -0
- package/dist/slices/project-templates/model.js +1 -0
- package/dist/slices/project-templates/repo.js +41 -0
- package/dist/slices/project-templates/schema.js +37 -0
- package/dist/slices/project-templates/service.js +136 -0
- package/dist/slices/project-templates/setup.js +55 -0
- package/dist/slices/rebuild/admission-repo.js +120 -0
- package/dist/slices/rebuild/dependencies.js +33 -0
- package/dist/slices/rebuild/model.js +94 -0
- package/dist/slices/rebuild/narration-history.js +43 -0
- package/dist/slices/rebuild/narration-reuse.js +20 -0
- package/dist/slices/rebuild/preview-details.js +144 -0
- package/dist/slices/rebuild/preview-plan.js +223 -0
- package/dist/slices/rebuild/preview-retained.js +128 -0
- package/dist/slices/rebuild/provided-review.js +32 -0
- package/dist/slices/rebuild/recipe-audio.js +172 -0
- package/dist/slices/rebuild/recipe-build.js +17 -0
- package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
- package/dist/slices/rebuild/recipe-exports.js +65 -0
- package/dist/slices/rebuild/recipe-fixture.js +150 -0
- package/dist/slices/rebuild/recipe-input-schema.js +82 -0
- package/dist/slices/rebuild/recipe-legacy.js +135 -0
- package/dist/slices/rebuild/recipe-model.js +77 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
- package/dist/slices/rebuild/recipe-save.js +221 -0
- package/dist/slices/rebuild/recipe-text.js +170 -0
- package/dist/slices/rebuild/recipe-validation.js +86 -0
- package/dist/slices/rebuild/recipe-visual.js +111 -0
- package/dist/slices/rebuild/recipe-work.js +212 -0
- package/dist/slices/rebuild/recipes.js +4 -0
- package/dist/slices/rebuild/repo.js +64 -0
- package/dist/slices/rebuild/runtime-actions.js +76 -0
- package/dist/slices/rebuild/runtime-admission.js +71 -0
- package/dist/slices/rebuild/runtime-article.js +117 -0
- package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
- package/dist/slices/rebuild/runtime-export.js +139 -0
- package/dist/slices/rebuild/runtime-instructions.js +51 -0
- package/dist/slices/rebuild/runtime-local.js +137 -0
- package/dist/slices/rebuild/runtime-materialize.js +105 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
- package/dist/slices/rebuild/runtime-plan.js +103 -0
- package/dist/slices/rebuild/runtime-provider.js +184 -0
- package/dist/slices/rebuild/runtime-publication.js +172 -0
- package/dist/slices/rebuild/runtime-run.js +35 -0
- package/dist/slices/rebuild/runtime-store.js +166 -0
- package/dist/slices/rebuild/runtime-subtitles.js +202 -0
- package/dist/slices/rebuild/service-readiness.js +110 -0
- package/dist/slices/rebuild/service.js +149 -0
- package/dist/slices/rebuild/transition-repo.js +108 -0
- package/dist/slices/rebuild/work-records.js +44 -0
- package/dist/slices/reruns/index.js +5 -0
- package/dist/slices/research/run.js +33 -12
- package/dist/slices/revisions/adopt-assets.js +31 -0
- package/dist/slices/revisions/adopt-content.js +112 -0
- package/dist/slices/revisions/adopt.js +100 -0
- package/dist/slices/revisions/downloads.js +80 -0
- package/dist/slices/revisions/index.js +5 -0
- package/dist/slices/revisions/manifest-repo.js +125 -0
- package/dist/slices/revisions/model.js +1 -0
- package/dist/slices/revisions/mutation-assets.js +143 -0
- package/dist/slices/revisions/mutation-cues.js +95 -0
- package/dist/slices/revisions/mutation-prepare.js +182 -0
- package/dist/slices/revisions/mutation-request.js +58 -0
- package/dist/slices/revisions/mutation-work.js +47 -0
- package/dist/slices/revisions/mutations.js +194 -0
- package/dist/slices/revisions/projection.js +82 -0
- package/dist/slices/revisions/publication-model.js +1 -0
- package/dist/slices/revisions/publication-rules.js +166 -0
- package/dist/slices/revisions/publish.js +115 -0
- package/dist/slices/revisions/repo.js +83 -0
- package/dist/slices/revisions/restore.js +60 -0
- package/dist/slices/revisions/rules.js +99 -0
- package/dist/slices/revisions/schema.js +160 -0
- package/dist/slices/revisions/view.js +44 -0
- package/dist/slices/schedules/calendar.js +47 -0
- package/dist/slices/schedules/model.js +1 -0
- package/dist/slices/schedules/repo.js +281 -0
- package/dist/slices/schedules/scheduler.js +144 -0
- package/dist/slices/schedules/schema.js +80 -0
- package/dist/slices/schedules/service.js +178 -0
- package/dist/slices/settings/cli-paths.js +1 -1
- package/dist/slices/settings/cli-status.js +45 -5
- package/dist/slices/settings/tutorial-schema.js +39 -0
- package/dist/slices/settings/tutorial.js +66 -0
- package/dist/slices/storage/assets.js +108 -0
- package/dist/slices/storage/delete-project.js +2 -1
- package/dist/slices/storage/downloads.js +4 -1
- package/dist/slices/storage/portable.js +871 -0
- package/dist/slices/storage/prepare.js +67 -0
- package/dist/slices/storage/reconcile.js +25 -5
- package/dist/slices/storage/repo.js +2 -15
- package/dist/slices/storage/schema.js +35 -0
- package/dist/slices/storage/staging-refs.js +26 -0
- package/dist/slices/storage/staging.js +16 -11
- package/dist/slices/thumbnail/run.js +26 -9
- package/dist/slices/video/audio-export-args.js +33 -0
- package/dist/slices/video/audio-export.js +13 -36
- package/dist/slices/video/audio-inputs.js +4 -3
- package/dist/slices/video/ffmpeg.js +6 -0
- package/dist/slices/video/reuse-audio.js +11 -4
- package/dist/updater/install-flow.js +27 -2
- package/dist/updater/plan.js +28 -2
- package/dist/updater/service.js +27 -2
- package/dist/updater/worker.js +2 -1
- package/dist/web/assets/index-CO7DnVX9.js +137 -0
- package/dist/web/assets/index-DX1_chKs.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/adapters/tts/inworld-text.js +0 -32
- package/dist/web/assets/index-Ce2HApus.css +0 -1
- package/dist/web/assets/index-xbD0MZOY.js +0 -130
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { crc32 } from "node:zlib";
|
|
5
|
+
import { strFromU8, strToU8, Unzip, UnzipInflate, zipSync } from "fflate";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
8
|
+
import { silenceGapSecondsMax } from "../admission/rules.js";
|
|
9
|
+
import { detectSlots } from "../admission/substitute.js";
|
|
10
|
+
import { fontMaxBytes } from "../fonts/model.js";
|
|
11
|
+
import { readFontMetadata } from "../fonts/sfnt.js";
|
|
12
|
+
import { lintEntry, lintPrompt } from "../library/lint.js";
|
|
13
|
+
import { entryCategories, entryModes, promptKinds } from "../library/model.js";
|
|
14
|
+
import { listEntries, listPrompts } from "../library/repo.js";
|
|
15
|
+
import { projectTemplateSchema } from "../project-templates/schema.js";
|
|
16
|
+
import { cliPathMaxLength } from "../settings/cli-paths.js";
|
|
17
|
+
import { appearances, providerById, providerIds } from "../settings/model.js";
|
|
18
|
+
import { listVoices } from "../settings/repo.js";
|
|
19
|
+
import { voiceIdMax, voiceNameMax } from "../settings/voices.js";
|
|
20
|
+
import { projectDir, stagingPath } from "./layout.js";
|
|
21
|
+
import { stageKinds } from "./model.js";
|
|
22
|
+
import { insertStagedFile, stagedFiles } from "./repo.js";
|
|
23
|
+
const record = z.record(z.string(), z.string());
|
|
24
|
+
export const portableMaxArchiveBytes = 100 * 1024 * 1024;
|
|
25
|
+
export const portableMaxExpandedBytes = 128 * 1024 * 1024;
|
|
26
|
+
export const portableMaxArchiveMembers = 1_024;
|
|
27
|
+
export const portableMaxManifestRows = 4_096;
|
|
28
|
+
const portableMaxManifestBytes = 16 * 1024 * 1024;
|
|
29
|
+
const portableMaxStagedFiles = portableMaxArchiveMembers - 65;
|
|
30
|
+
const boundedRecord = record.refine((value) => Object.keys(value).length <= portableMaxManifestRows, `A backup can contain at most ${portableMaxManifestRows} settings.`);
|
|
31
|
+
const promptRow = z
|
|
32
|
+
.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
kind: z.enum(promptKinds),
|
|
35
|
+
name: z.string(),
|
|
36
|
+
body: z.string(),
|
|
37
|
+
slots: z.string(),
|
|
38
|
+
updated_at: z.string(),
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
const libraryRow = z
|
|
42
|
+
.object({
|
|
43
|
+
id: z.string(),
|
|
44
|
+
name: z.string(),
|
|
45
|
+
body: z.string(),
|
|
46
|
+
slots: z.string(),
|
|
47
|
+
updated_at: z.string(),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
const voiceRow = z
|
|
51
|
+
.object({ id: z.string(), provider: z.enum(providerIds), name: z.string(), voice_id: z.string() })
|
|
52
|
+
.strict();
|
|
53
|
+
const stagedMeta = z
|
|
54
|
+
.object({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
stageKind: z.enum(stageKinds),
|
|
57
|
+
originalFilename: z.string(),
|
|
58
|
+
bytes: z.number().int().nonnegative(),
|
|
59
|
+
archivePath: z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1)
|
|
62
|
+
.refine((value) => value.startsWith("staging/") && safeArchiveMemberName(value), "A staged archive path must stay inside staging/."),
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
const uploadedFontId = z.string().regex(/^uploaded-[a-f0-9]{64}$/);
|
|
66
|
+
const fontMeta = z
|
|
67
|
+
.object({
|
|
68
|
+
id: uploadedFontId,
|
|
69
|
+
extension: z.enum([".ttf", ".otf"]),
|
|
70
|
+
bytes: z.number().int().min(12).max(fontMaxBytes),
|
|
71
|
+
})
|
|
72
|
+
.strict();
|
|
73
|
+
const manifestSchema = z
|
|
74
|
+
.object({
|
|
75
|
+
version: z.literal(1),
|
|
76
|
+
createdAt: z.string(),
|
|
77
|
+
settings: boundedRecord,
|
|
78
|
+
prompts: z.array(promptRow).max(portableMaxManifestRows),
|
|
79
|
+
entries: z
|
|
80
|
+
.array(libraryRow.extend({ category: z.enum(entryCategories), mode: z.enum(entryModes) }))
|
|
81
|
+
.max(portableMaxManifestRows),
|
|
82
|
+
voices: z.array(voiceRow).max(portableMaxManifestRows),
|
|
83
|
+
templates: z.array(projectTemplateSchema).max(portableMaxManifestRows),
|
|
84
|
+
fonts: z.array(fontMeta).max(64).default([]),
|
|
85
|
+
staged: z.array(stagedMeta).max(portableMaxStagedFiles),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
export function exportPortable(deps) {
|
|
89
|
+
const entries = {};
|
|
90
|
+
const stagedPlan = planStagedExport(deps);
|
|
91
|
+
const staged = stagedPlan.map(({ archivePath, file, path }) => {
|
|
92
|
+
entries[archivePath] = [readStagedExport(path, file.bytes), { level: 0 }];
|
|
93
|
+
return {
|
|
94
|
+
id: file.id,
|
|
95
|
+
stageKind: file.stageKind,
|
|
96
|
+
originalFilename: file.originalFilename,
|
|
97
|
+
bytes: file.bytes,
|
|
98
|
+
archivePath,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const storedSettings = {};
|
|
102
|
+
for (const row of deps.db.prepare("SELECT key,value FROM settings ORDER BY key").all()) {
|
|
103
|
+
if (typeof row.key === "string" && typeof row.value === "string")
|
|
104
|
+
storedSettings[row.key] = row.value;
|
|
105
|
+
}
|
|
106
|
+
const settings = portableSettings(storedSettings);
|
|
107
|
+
const prompts = listPrompts(deps.db).map((prompt) => ({
|
|
108
|
+
id: prompt.id,
|
|
109
|
+
kind: prompt.kind,
|
|
110
|
+
name: prompt.name,
|
|
111
|
+
body: prompt.body,
|
|
112
|
+
slots: JSON.stringify(prompt.slots),
|
|
113
|
+
updated_at: prompt.updatedAt,
|
|
114
|
+
}));
|
|
115
|
+
const entryRows = listEntries(deps.db).map((entry) => ({
|
|
116
|
+
id: entry.id,
|
|
117
|
+
category: entry.category,
|
|
118
|
+
mode: entry.mode,
|
|
119
|
+
name: entry.name,
|
|
120
|
+
body: entry.body,
|
|
121
|
+
slots: JSON.stringify(entry.slots),
|
|
122
|
+
updated_at: entry.updatedAt,
|
|
123
|
+
}));
|
|
124
|
+
const voices = listVoices(deps.db).map((voice) => ({
|
|
125
|
+
id: voice.id,
|
|
126
|
+
provider: voice.provider,
|
|
127
|
+
name: voice.name,
|
|
128
|
+
voice_id: voice.voiceId,
|
|
129
|
+
}));
|
|
130
|
+
const templates = deps.db
|
|
131
|
+
.prepare("SELECT t.id,r.version,r.name,t.created_at AS createdAt,r.created_at AS updatedAt,r.document_json AS document FROM project_templates t JOIN project_template_revisions r ON r.template_id=t.id AND r.version=t.head_version ORDER BY t.id")
|
|
132
|
+
.all()
|
|
133
|
+
.map((row) => projectTemplateSchema.parse({ ...row, document: JSON.parse(String(row.document)) }));
|
|
134
|
+
const fonts = exportUploadedFonts(deps.paths, entries);
|
|
135
|
+
const includedFonts = new Set(fonts.map((font) => font.id));
|
|
136
|
+
for (const template of templates) {
|
|
137
|
+
const id = template.document.form.subtitles.fontId;
|
|
138
|
+
if (id.startsWith("uploaded-") && !includedFonts.has(id))
|
|
139
|
+
throw new Error(`Template ${template.id} refers to an unavailable uploaded font.`);
|
|
140
|
+
}
|
|
141
|
+
const manifest = manifestSchema.parse({
|
|
142
|
+
version: 1,
|
|
143
|
+
createdAt: deps.now(),
|
|
144
|
+
settings,
|
|
145
|
+
prompts,
|
|
146
|
+
entries: entryRows,
|
|
147
|
+
voices,
|
|
148
|
+
templates,
|
|
149
|
+
fonts,
|
|
150
|
+
staged,
|
|
151
|
+
});
|
|
152
|
+
validateLibraryRows(manifest);
|
|
153
|
+
const manifestBytes = strToU8(JSON.stringify(manifest));
|
|
154
|
+
if (manifestBytes.byteLength > portableMaxManifestBytes)
|
|
155
|
+
throw new Error("The backup manifest is too large.");
|
|
156
|
+
entries["manifest.json"] = [manifestBytes, { level: 0 }];
|
|
157
|
+
validateExportSize(entries);
|
|
158
|
+
const archive = zipSync(entries);
|
|
159
|
+
if (archive.byteLength > portableMaxArchiveBytes)
|
|
160
|
+
throw new Error("The portable backup is larger than 100 MiB.");
|
|
161
|
+
return archive;
|
|
162
|
+
}
|
|
163
|
+
export function importPortable(deps, bytes) {
|
|
164
|
+
const archive = materializePortableArchive(bytes);
|
|
165
|
+
const manifestBytes = archive["manifest.json"];
|
|
166
|
+
if (manifestBytes === undefined)
|
|
167
|
+
throw new Error("The backup has no manifest.");
|
|
168
|
+
if (manifestBytes.byteLength > portableMaxManifestBytes)
|
|
169
|
+
throw new Error("The backup manifest is too large.");
|
|
170
|
+
const manifest = manifestSchema.parse(JSON.parse(strFromU8(manifestBytes)));
|
|
171
|
+
validateManifestMembers(archive, manifest);
|
|
172
|
+
validateLibraryRows(manifest);
|
|
173
|
+
const settingsToImport = portableSettings(manifest.settings);
|
|
174
|
+
const plannedFonts = planFontImport(deps.paths, archive, manifest.fonts);
|
|
175
|
+
const staged = planStagedImport(deps, archive, manifest.staged);
|
|
176
|
+
const availableFonts = new Set([
|
|
177
|
+
...installedUploadedFonts(deps.paths).map((font) => font.id),
|
|
178
|
+
...manifest.fonts.map((font) => font.id),
|
|
179
|
+
]);
|
|
180
|
+
const created = [];
|
|
181
|
+
try {
|
|
182
|
+
return transact(deps.db, () => {
|
|
183
|
+
if (plannedFonts.length > 0)
|
|
184
|
+
mkdirSync(join(deps.paths.dataDir, "fonts"), { recursive: true, mode: 0o700 });
|
|
185
|
+
for (const font of plannedFonts)
|
|
186
|
+
writeImportedFile(deps, font.path, font.content, created);
|
|
187
|
+
let settings = 0;
|
|
188
|
+
for (const [key, value] of Object.entries(settingsToImport)) {
|
|
189
|
+
deps.db
|
|
190
|
+
.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
|
|
191
|
+
.run(key, value);
|
|
192
|
+
settings += 1;
|
|
193
|
+
}
|
|
194
|
+
let prompts = 0;
|
|
195
|
+
for (const row of manifest.prompts) {
|
|
196
|
+
deps.db
|
|
197
|
+
.prepare("INSERT INTO prompts(id,kind,name,body,slots,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,name=excluded.name,body=excluded.body,slots=excluded.slots,updated_at=excluded.updated_at")
|
|
198
|
+
.run(row.id, row.kind, row.name, row.body, row.slots, row.updated_at);
|
|
199
|
+
prompts += 1;
|
|
200
|
+
}
|
|
201
|
+
let entries = 0;
|
|
202
|
+
for (const row of manifest.entries) {
|
|
203
|
+
deps.db
|
|
204
|
+
.prepare("INSERT INTO entries(id,category,mode,name,body,slots,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET category=excluded.category,mode=excluded.mode,name=excluded.name,body=excluded.body,slots=excluded.slots,updated_at=excluded.updated_at")
|
|
205
|
+
.run(row.id, row.category, row.mode, row.name, row.body, row.slots, row.updated_at);
|
|
206
|
+
entries += 1;
|
|
207
|
+
}
|
|
208
|
+
let voices = 0;
|
|
209
|
+
for (const row of manifest.voices) {
|
|
210
|
+
deps.db
|
|
211
|
+
.prepare("INSERT INTO voices(id,provider,name,voice_id) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET provider=excluded.provider,name=excluded.name,voice_id=excluded.voice_id")
|
|
212
|
+
.run(row.id, row.provider, row.name, row.voice_id);
|
|
213
|
+
voices += 1;
|
|
214
|
+
}
|
|
215
|
+
let templateCount = 0;
|
|
216
|
+
let fontFallbacks = 0;
|
|
217
|
+
for (const sourceTemplate of manifest.templates) {
|
|
218
|
+
const exists = deps.db
|
|
219
|
+
.prepare("SELECT 1 FROM project_templates WHERE id=?")
|
|
220
|
+
.get(sourceTemplate.id);
|
|
221
|
+
if (exists !== undefined)
|
|
222
|
+
continue;
|
|
223
|
+
const template = repairMissingUploadedFont(sourceTemplate, availableFonts);
|
|
224
|
+
if (template !== sourceTemplate)
|
|
225
|
+
fontFallbacks += 1;
|
|
226
|
+
deps.db
|
|
227
|
+
.prepare("INSERT INTO project_templates(id,head_version,creation_hash,created_at) VALUES(?,?,?,?)")
|
|
228
|
+
.run(template.id, template.version, `portable:${template.id}:${template.version}`, template.createdAt);
|
|
229
|
+
deps.db
|
|
230
|
+
.prepare("INSERT INTO project_template_revisions(template_id,version,name,document_json,created_at) VALUES(?,?,?,?,?)")
|
|
231
|
+
.run(template.id, template.version, template.name, JSON.stringify(template.document), template.updatedAt);
|
|
232
|
+
templateCount += 1;
|
|
233
|
+
}
|
|
234
|
+
for (const file of staged) {
|
|
235
|
+
writeImportedFile(deps, file.path, file.content, created);
|
|
236
|
+
insertStagedFile(deps.db, file.row);
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
settings,
|
|
240
|
+
prompts,
|
|
241
|
+
entries,
|
|
242
|
+
voices,
|
|
243
|
+
templates: templateCount,
|
|
244
|
+
fonts: plannedFonts.length,
|
|
245
|
+
fontFallbacks,
|
|
246
|
+
stagedFiles: staged.length,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
for (const path of created.reverse())
|
|
252
|
+
rmSync(path, { force: true });
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function planStagedExport(deps) {
|
|
257
|
+
const planned = [];
|
|
258
|
+
let bytes = 0;
|
|
259
|
+
for (const file of stagedFiles(deps.db)) {
|
|
260
|
+
if (file.state !== "staged" || file.path !== file.id)
|
|
261
|
+
continue;
|
|
262
|
+
const archivePath = `staging/${file.id}`;
|
|
263
|
+
if (!safeArchiveMemberName(archivePath))
|
|
264
|
+
continue;
|
|
265
|
+
const path = stagingPath(deps.paths, file.path);
|
|
266
|
+
let stat;
|
|
267
|
+
try {
|
|
268
|
+
stat = lstatSync(path);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (unavailable(error))
|
|
272
|
+
continue;
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
if (!stat.isFile() ||
|
|
276
|
+
!Number.isSafeInteger(stat.size) ||
|
|
277
|
+
stat.size <= 0 ||
|
|
278
|
+
stat.size !== file.bytes)
|
|
279
|
+
continue;
|
|
280
|
+
if (planned.length >= portableMaxStagedFiles)
|
|
281
|
+
throw new Error("A portable backup contains too many staged files.");
|
|
282
|
+
if (stat.size > portableMaxArchiveBytes - bytes)
|
|
283
|
+
throw new Error("The staged files are too large for a portable backup.");
|
|
284
|
+
bytes += stat.size;
|
|
285
|
+
planned.push({ archivePath, file, path });
|
|
286
|
+
}
|
|
287
|
+
return planned;
|
|
288
|
+
}
|
|
289
|
+
function readStagedExport(path, expectedBytes) {
|
|
290
|
+
let descriptor;
|
|
291
|
+
try {
|
|
292
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
293
|
+
const stat = fstatSync(descriptor);
|
|
294
|
+
if (!stat.isFile() || stat.size !== expectedBytes)
|
|
295
|
+
throw new Error("A staged file changed while the portable backup was being created.");
|
|
296
|
+
const content = readFileSync(descriptor);
|
|
297
|
+
if (content.byteLength !== expectedBytes)
|
|
298
|
+
throw new Error("A staged file changed while the portable backup was being created.");
|
|
299
|
+
return content;
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
if (descriptor !== undefined)
|
|
303
|
+
closeSync(descriptor);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function validateExportSize(entries) {
|
|
307
|
+
const names = Object.keys(entries);
|
|
308
|
+
if (names.length > portableMaxArchiveMembers)
|
|
309
|
+
throw new Error("A portable backup contains too many archive members.");
|
|
310
|
+
let bytes = 0;
|
|
311
|
+
for (const name of names) {
|
|
312
|
+
const entry = entries[name];
|
|
313
|
+
if (entry === undefined || !safeArchiveMemberName(name))
|
|
314
|
+
throw new Error("A portable backup contains an invalid archive member.");
|
|
315
|
+
if (entry[0].byteLength > portableMaxExpandedBytes - bytes)
|
|
316
|
+
throw new Error("A portable backup contains too much file data.");
|
|
317
|
+
bytes += entry[0].byteLength;
|
|
318
|
+
}
|
|
319
|
+
if (bytes > portableMaxArchiveBytes)
|
|
320
|
+
throw new Error("A portable backup contains too much uncompressed file data.");
|
|
321
|
+
}
|
|
322
|
+
const slotsColumn = z.array(z.string());
|
|
323
|
+
const storedCliPath = z.string().max(cliPathMaxLength).nullable();
|
|
324
|
+
const storedSilenceGap = z.number().int().min(0).max(silenceGapSecondsMax);
|
|
325
|
+
const storedAppearance = z.enum(appearances);
|
|
326
|
+
function portableSettings(settings) {
|
|
327
|
+
const portable = {};
|
|
328
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
329
|
+
if (key === "tutorial.session")
|
|
330
|
+
continue;
|
|
331
|
+
const parsed = storedJson(value);
|
|
332
|
+
if (key === "silenceGapSeconds")
|
|
333
|
+
storedSilenceGap.parse(parsed);
|
|
334
|
+
else if (key === "appearance")
|
|
335
|
+
storedAppearance.parse(parsed);
|
|
336
|
+
else if (key.startsWith("cli.path.")) {
|
|
337
|
+
const provider = providerById(z.enum(providerIds).parse(key.slice("cli.path.".length)));
|
|
338
|
+
if (provider.auth !== "cli")
|
|
339
|
+
throw new Error("A CLI path names a provider without a CLI.");
|
|
340
|
+
storedCliPath.parse(parsed);
|
|
341
|
+
}
|
|
342
|
+
else
|
|
343
|
+
throw new Error(`The backup contains an unknown setting ${key}.`);
|
|
344
|
+
portable[key] = value;
|
|
345
|
+
}
|
|
346
|
+
return portable;
|
|
347
|
+
}
|
|
348
|
+
function storedJson(value) {
|
|
349
|
+
try {
|
|
350
|
+
return JSON.parse(value);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new Error("A portable setting does not contain valid JSON.");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function validateLibraryRows(manifest) {
|
|
357
|
+
const promptIds = new Set();
|
|
358
|
+
const promptNames = new Set();
|
|
359
|
+
for (const row of manifest.prompts) {
|
|
360
|
+
if (row.name !== row.name.trim() || lintPrompt(row).length > 0)
|
|
361
|
+
throw new Error("The backup contains an invalid prompt.");
|
|
362
|
+
uniquePortableRow(promptIds, row.id, "prompt id");
|
|
363
|
+
uniquePortableRow(promptNames, `${row.kind}\0${row.name.toLowerCase()}`, "prompt name");
|
|
364
|
+
validateSlots(row.body, row.slots);
|
|
365
|
+
}
|
|
366
|
+
const entryIds = new Set();
|
|
367
|
+
const entryNames = new Set();
|
|
368
|
+
for (const row of manifest.entries) {
|
|
369
|
+
if (row.name !== row.name.trim() || lintEntry(row).length > 0)
|
|
370
|
+
throw new Error("The backup contains an invalid entry.");
|
|
371
|
+
uniquePortableRow(entryIds, row.id, "entry id");
|
|
372
|
+
uniquePortableRow(entryNames, `${row.category}\0${row.name.toLowerCase()}`, "entry name");
|
|
373
|
+
validateSlots(row.body, row.slots);
|
|
374
|
+
}
|
|
375
|
+
const voiceIds = new Set();
|
|
376
|
+
const providerVoiceIds = new Set();
|
|
377
|
+
for (const row of manifest.voices) {
|
|
378
|
+
if (providerById(row.provider).family !== "tts" ||
|
|
379
|
+
row.name !== row.name.trim() ||
|
|
380
|
+
row.name.length === 0 ||
|
|
381
|
+
row.name.length > voiceNameMax ||
|
|
382
|
+
row.voice_id !== row.voice_id.trim() ||
|
|
383
|
+
row.voice_id.length === 0 ||
|
|
384
|
+
row.voice_id.length > voiceIdMax)
|
|
385
|
+
throw new Error("The backup contains an invalid narration voice.");
|
|
386
|
+
uniquePortableRow(voiceIds, row.id, "voice id");
|
|
387
|
+
uniquePortableRow(providerVoiceIds, `${row.provider}\0${row.voice_id}`, "provider voice id");
|
|
388
|
+
}
|
|
389
|
+
const templateIds = new Set();
|
|
390
|
+
for (const template of manifest.templates)
|
|
391
|
+
uniquePortableRow(templateIds, template.id, "project template id");
|
|
392
|
+
}
|
|
393
|
+
function uniquePortableRow(seen, key, field) {
|
|
394
|
+
if (seen.has(key))
|
|
395
|
+
throw new Error(`The backup repeats a ${field}.`);
|
|
396
|
+
seen.add(key);
|
|
397
|
+
}
|
|
398
|
+
function validateSlots(body, encoded) {
|
|
399
|
+
let parsed;
|
|
400
|
+
try {
|
|
401
|
+
parsed = JSON.parse(encoded);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
throw new Error("The backup contains invalid template slots.");
|
|
405
|
+
}
|
|
406
|
+
const slots = slotsColumn.parse(parsed);
|
|
407
|
+
const detected = detectSlots(body);
|
|
408
|
+
if (detected.errors.length > 0 ||
|
|
409
|
+
slots.length !== detected.names.length ||
|
|
410
|
+
slots.some((slot, index) => slot !== detected.names[index]))
|
|
411
|
+
throw new Error("The backup template slots do not match its body.");
|
|
412
|
+
}
|
|
413
|
+
function materializePortableArchive(bytes) {
|
|
414
|
+
if (bytes.byteLength === 0 || bytes.byteLength > portableMaxArchiveBytes)
|
|
415
|
+
throw new Error("The compressed backup is outside the supported size limit.");
|
|
416
|
+
const directory = zipDirectory(bytes);
|
|
417
|
+
const archive = Object.create(null);
|
|
418
|
+
const names = new Set();
|
|
419
|
+
let expandedBytes = 0;
|
|
420
|
+
let members = 0;
|
|
421
|
+
let failure;
|
|
422
|
+
const unzip = new Unzip((file) => {
|
|
423
|
+
if (failure !== undefined) {
|
|
424
|
+
file.terminate();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
members += 1;
|
|
429
|
+
if (members > portableMaxArchiveMembers)
|
|
430
|
+
throw new Error("The backup contains too many archive members.");
|
|
431
|
+
if (!safeArchiveMemberName(file.name) || names.has(file.name))
|
|
432
|
+
throw new Error("The backup contains an invalid or repeated archive member.");
|
|
433
|
+
names.add(file.name);
|
|
434
|
+
const expected = directory.get(file.name);
|
|
435
|
+
if (expected === undefined)
|
|
436
|
+
throw new Error("The backup local files do not match its central directory.");
|
|
437
|
+
if ((file.size !== undefined && (!Number.isSafeInteger(file.size) || file.size < 0)) ||
|
|
438
|
+
(file.originalSize !== undefined &&
|
|
439
|
+
(!Number.isSafeInteger(file.originalSize) || file.originalSize < 0)) ||
|
|
440
|
+
file.compression !== expected.compression ||
|
|
441
|
+
(file.size !== undefined && file.size !== expected.compressedBytes) ||
|
|
442
|
+
(file.originalSize !== undefined && file.originalSize !== expected.expandedBytes))
|
|
443
|
+
throw new Error("The backup contains invalid archive metadata.");
|
|
444
|
+
const chunks = [];
|
|
445
|
+
let memberBytes = 0;
|
|
446
|
+
let memberCrc = 0;
|
|
447
|
+
file.ondata = (error, chunk, final) => {
|
|
448
|
+
if (failure !== undefined)
|
|
449
|
+
return;
|
|
450
|
+
if (error !== null) {
|
|
451
|
+
failure = error;
|
|
452
|
+
file.terminate();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (chunk.byteLength > expected.expandedBytes - memberBytes) {
|
|
456
|
+
failure = new Error("The backup contains inconsistent archive metadata.");
|
|
457
|
+
file.terminate();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (chunk.byteLength > portableMaxExpandedBytes - expandedBytes) {
|
|
461
|
+
failure = new Error("The backup expands beyond the supported size limit.");
|
|
462
|
+
file.terminate();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
expandedBytes += chunk.byteLength;
|
|
466
|
+
memberBytes += chunk.byteLength;
|
|
467
|
+
memberCrc = crc32(chunk, memberCrc);
|
|
468
|
+
chunks.push(chunk);
|
|
469
|
+
if (!final)
|
|
470
|
+
return;
|
|
471
|
+
if (memberBytes !== expected.expandedBytes || memberCrc !== expected.crc) {
|
|
472
|
+
failure = new Error("The backup contains inconsistent archive metadata.");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
archive[file.name] = joinChunks(chunks, memberBytes);
|
|
476
|
+
};
|
|
477
|
+
file.start();
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
failure = error;
|
|
481
|
+
file.terminate();
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
unzip.register(UnzipInflate);
|
|
485
|
+
const compressedChunkBytes = 16 * 1024;
|
|
486
|
+
for (let offset = 0; offset < bytes.byteLength; offset += compressedChunkBytes) {
|
|
487
|
+
const end = Math.min(offset + compressedChunkBytes, bytes.byteLength);
|
|
488
|
+
unzip.push(bytes.subarray(offset, end), end === bytes.byteLength);
|
|
489
|
+
if (failure !== undefined)
|
|
490
|
+
throw failure;
|
|
491
|
+
}
|
|
492
|
+
if (failure !== undefined)
|
|
493
|
+
throw failure;
|
|
494
|
+
if (Object.keys(archive).length !== members || members !== directory.size)
|
|
495
|
+
throw new Error("The backup does not contain every central-directory member.");
|
|
496
|
+
return archive;
|
|
497
|
+
}
|
|
498
|
+
function zipDirectory(bytes) {
|
|
499
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
500
|
+
const eocd = endOfCentralDirectory(view);
|
|
501
|
+
const disk = uint16(view, eocd + 4);
|
|
502
|
+
const centralDisk = uint16(view, eocd + 6);
|
|
503
|
+
const diskEntries = uint16(view, eocd + 8);
|
|
504
|
+
const count = uint16(view, eocd + 10);
|
|
505
|
+
const centralBytes = uint32(view, eocd + 12);
|
|
506
|
+
const centralOffset = uint32(view, eocd + 16);
|
|
507
|
+
if (disk !== 0 ||
|
|
508
|
+
centralDisk !== 0 ||
|
|
509
|
+
diskEntries !== count ||
|
|
510
|
+
count === 0xffff ||
|
|
511
|
+
centralBytes === 0xffffffff ||
|
|
512
|
+
centralOffset === 0xffffffff)
|
|
513
|
+
throw new Error("The backup uses an unsupported multi-disk or ZIP64 structure.");
|
|
514
|
+
if (count > portableMaxArchiveMembers)
|
|
515
|
+
throw new Error("The backup contains too many archive members.");
|
|
516
|
+
const centralEnd = centralOffset + centralBytes;
|
|
517
|
+
if (centralOffset > eocd || centralEnd !== eocd)
|
|
518
|
+
throw new Error("The backup central directory is incomplete.");
|
|
519
|
+
const members = new Map();
|
|
520
|
+
const ranges = [];
|
|
521
|
+
let expandedBytes = 0;
|
|
522
|
+
let cursor = centralOffset;
|
|
523
|
+
for (let index = 0; index < count; index += 1) {
|
|
524
|
+
if (uint32(view, cursor) !== 0x02014b50)
|
|
525
|
+
throw new Error("The backup central directory is invalid.");
|
|
526
|
+
const flags = uint16(view, cursor + 8);
|
|
527
|
+
const compression = uint16(view, cursor + 10);
|
|
528
|
+
const crc = uint32(view, cursor + 16);
|
|
529
|
+
const compressedBytes = uint32(view, cursor + 20);
|
|
530
|
+
const memberExpandedBytes = uint32(view, cursor + 24);
|
|
531
|
+
const nameBytes = uint16(view, cursor + 28);
|
|
532
|
+
const extraBytes = uint16(view, cursor + 30);
|
|
533
|
+
const commentBytes = uint16(view, cursor + 32);
|
|
534
|
+
const startDisk = uint16(view, cursor + 34);
|
|
535
|
+
const localOffset = uint32(view, cursor + 42);
|
|
536
|
+
const next = cursor + 46 + nameBytes + extraBytes + commentBytes;
|
|
537
|
+
if (next > centralEnd || startDisk !== 0 || (flags & ~0x080e) !== 0)
|
|
538
|
+
throw new Error("The backup central directory is invalid.");
|
|
539
|
+
const name = strFromU8(bytes.subarray(cursor + 46, cursor + 46 + nameBytes), (flags & 0x0800) === 0);
|
|
540
|
+
if (!safeArchiveMemberName(name) ||
|
|
541
|
+
members.has(name) ||
|
|
542
|
+
(compression !== 0 && compression !== 8) ||
|
|
543
|
+
(compression === 0 && compressedBytes !== memberExpandedBytes))
|
|
544
|
+
throw new Error("The backup contains invalid central-directory metadata.");
|
|
545
|
+
if (name === "manifest.json" && memberExpandedBytes > portableMaxManifestBytes)
|
|
546
|
+
throw new Error("The backup manifest is too large.");
|
|
547
|
+
if (memberExpandedBytes > portableMaxExpandedBytes - expandedBytes)
|
|
548
|
+
throw new Error("The backup expands beyond the supported size limit.");
|
|
549
|
+
expandedBytes += memberExpandedBytes;
|
|
550
|
+
const dataStart = validateLocalHeader({
|
|
551
|
+
bytes,
|
|
552
|
+
view,
|
|
553
|
+
localOffset,
|
|
554
|
+
centralOffset,
|
|
555
|
+
name,
|
|
556
|
+
flags,
|
|
557
|
+
compression,
|
|
558
|
+
crc,
|
|
559
|
+
compressedBytes,
|
|
560
|
+
expandedBytes: memberExpandedBytes,
|
|
561
|
+
});
|
|
562
|
+
const dataEnd = dataStart + compressedBytes;
|
|
563
|
+
if (dataEnd > centralOffset)
|
|
564
|
+
throw new Error("The backup member data crosses its central directory.");
|
|
565
|
+
const memberEnd = (flags & 0x0008) === 0
|
|
566
|
+
? dataEnd
|
|
567
|
+
: dataDescriptorEnd(view, dataEnd, centralOffset, {
|
|
568
|
+
crc,
|
|
569
|
+
compressedBytes,
|
|
570
|
+
expandedBytes: memberExpandedBytes,
|
|
571
|
+
});
|
|
572
|
+
ranges.push({ start: localOffset, end: memberEnd });
|
|
573
|
+
members.set(name, {
|
|
574
|
+
name,
|
|
575
|
+
compression,
|
|
576
|
+
crc,
|
|
577
|
+
compressedBytes,
|
|
578
|
+
expandedBytes: memberExpandedBytes,
|
|
579
|
+
});
|
|
580
|
+
cursor = next;
|
|
581
|
+
}
|
|
582
|
+
if (cursor !== centralEnd)
|
|
583
|
+
throw new Error("The backup central-directory size is inconsistent.");
|
|
584
|
+
const ordered = ranges.toSorted((left, right) => left.start - right.start);
|
|
585
|
+
if (ordered[0]?.start !== 0 || ordered.at(-1)?.end !== centralOffset)
|
|
586
|
+
throw new Error("The backup contains data outside its declared archive members.");
|
|
587
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
588
|
+
const previous = ordered[index - 1];
|
|
589
|
+
const current = ordered[index];
|
|
590
|
+
if (previous === undefined || current === undefined || previous.end !== current.start)
|
|
591
|
+
throw new Error("The backup archive-member spans are not contiguous.");
|
|
592
|
+
}
|
|
593
|
+
return members;
|
|
594
|
+
}
|
|
595
|
+
function validateLocalHeader(input) {
|
|
596
|
+
const { bytes, view, localOffset } = input;
|
|
597
|
+
if (localOffset + 30 > input.centralOffset || uint32(view, localOffset) !== 0x04034b50)
|
|
598
|
+
throw new Error("The backup local header is missing.");
|
|
599
|
+
const flags = uint16(view, localOffset + 6);
|
|
600
|
+
const compression = uint16(view, localOffset + 8);
|
|
601
|
+
const nameBytes = uint16(view, localOffset + 26);
|
|
602
|
+
const extraBytes = uint16(view, localOffset + 28);
|
|
603
|
+
const dataStart = localOffset + 30 + nameBytes + extraBytes;
|
|
604
|
+
if (dataStart > input.centralOffset || flags !== input.flags || compression !== input.compression)
|
|
605
|
+
throw new Error("The backup local header is invalid.");
|
|
606
|
+
const name = strFromU8(bytes.subarray(localOffset + 30, localOffset + 30 + nameBytes), (flags & 0x0800) === 0);
|
|
607
|
+
if (name !== input.name)
|
|
608
|
+
throw new Error("The backup local and central filenames differ.");
|
|
609
|
+
if ((flags & 0x0008) === 0) {
|
|
610
|
+
if (uint32(view, localOffset + 14) !== input.crc ||
|
|
611
|
+
uint32(view, localOffset + 18) !== input.compressedBytes ||
|
|
612
|
+
uint32(view, localOffset + 22) !== input.expandedBytes)
|
|
613
|
+
throw new Error("The backup local and central metadata differ.");
|
|
614
|
+
}
|
|
615
|
+
return dataStart;
|
|
616
|
+
}
|
|
617
|
+
function dataDescriptorEnd(view, offset, centralOffset, expected) {
|
|
618
|
+
if (offset + 16 <= centralOffset &&
|
|
619
|
+
uint32(view, offset) === 0x08074b50 &&
|
|
620
|
+
uint32(view, offset + 4) === expected.crc &&
|
|
621
|
+
uint32(view, offset + 8) === expected.compressedBytes &&
|
|
622
|
+
uint32(view, offset + 12) === expected.expandedBytes)
|
|
623
|
+
return offset + 16;
|
|
624
|
+
if (offset + 12 <= centralOffset &&
|
|
625
|
+
uint32(view, offset) === expected.crc &&
|
|
626
|
+
uint32(view, offset + 4) === expected.compressedBytes &&
|
|
627
|
+
uint32(view, offset + 8) === expected.expandedBytes)
|
|
628
|
+
return offset + 12;
|
|
629
|
+
throw new Error("The backup data descriptor is invalid or missing.");
|
|
630
|
+
}
|
|
631
|
+
function endOfCentralDirectory(view) {
|
|
632
|
+
if (view.byteLength < 22)
|
|
633
|
+
throw new Error("The backup has no end-of-central-directory record.");
|
|
634
|
+
const earliest = Math.max(0, view.byteLength - 22 - 0xffff);
|
|
635
|
+
for (let offset = view.byteLength - 22; offset >= earliest; offset -= 1) {
|
|
636
|
+
if (uint32(view, offset) === 0x06054b50 &&
|
|
637
|
+
offset + 22 + uint16(view, offset + 20) === view.byteLength)
|
|
638
|
+
return offset;
|
|
639
|
+
}
|
|
640
|
+
throw new Error("The backup has no complete end-of-central-directory record.");
|
|
641
|
+
}
|
|
642
|
+
function uint16(view, offset) {
|
|
643
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset + 2 > view.byteLength)
|
|
644
|
+
throw new Error("The backup ZIP structure is truncated.");
|
|
645
|
+
return view.getUint16(offset, true);
|
|
646
|
+
}
|
|
647
|
+
function uint32(view, offset) {
|
|
648
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset + 4 > view.byteLength)
|
|
649
|
+
throw new Error("The backup ZIP structure is truncated.");
|
|
650
|
+
return view.getUint32(offset, true);
|
|
651
|
+
}
|
|
652
|
+
function joinChunks(chunks, bytes) {
|
|
653
|
+
const joined = new Uint8Array(bytes);
|
|
654
|
+
let offset = 0;
|
|
655
|
+
for (const chunk of chunks) {
|
|
656
|
+
joined.set(chunk, offset);
|
|
657
|
+
offset += chunk.byteLength;
|
|
658
|
+
}
|
|
659
|
+
return joined;
|
|
660
|
+
}
|
|
661
|
+
function safeArchiveMemberName(name) {
|
|
662
|
+
if (name.length === 0 ||
|
|
663
|
+
name.length > 512 ||
|
|
664
|
+
name.startsWith("/") ||
|
|
665
|
+
name.includes("\\") ||
|
|
666
|
+
name.includes("\0"))
|
|
667
|
+
return false;
|
|
668
|
+
return name
|
|
669
|
+
.split("/")
|
|
670
|
+
.every((part) => part.length > 0 &&
|
|
671
|
+
part !== "." &&
|
|
672
|
+
part !== ".." &&
|
|
673
|
+
part !== "__proto__" &&
|
|
674
|
+
part !== "prototype" &&
|
|
675
|
+
part !== "constructor");
|
|
676
|
+
}
|
|
677
|
+
function validateManifestMembers(archive, manifest) {
|
|
678
|
+
const expected = new Set([
|
|
679
|
+
"manifest.json",
|
|
680
|
+
...manifest.fonts.map((font) => fontArchivePath(font.id, font.extension)),
|
|
681
|
+
...manifest.staged.map((file) => file.archivePath),
|
|
682
|
+
]);
|
|
683
|
+
if (Object.keys(archive).some((name) => !expected.has(name)))
|
|
684
|
+
throw new Error("The backup contains unlisted archive members.");
|
|
685
|
+
}
|
|
686
|
+
function repairMissingUploadedFont(template, available) {
|
|
687
|
+
const fontId = template.document.form.subtitles.fontId;
|
|
688
|
+
if (!fontId.startsWith("uploaded-") || available.has(fontId))
|
|
689
|
+
return template;
|
|
690
|
+
return {
|
|
691
|
+
...template,
|
|
692
|
+
document: {
|
|
693
|
+
...template.document,
|
|
694
|
+
form: {
|
|
695
|
+
...template.document.form,
|
|
696
|
+
subtitles: { ...template.document.form.subtitles, fontId: "default" },
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function exportUploadedFonts(paths, archive) {
|
|
702
|
+
const fonts = installedUploadedFonts(paths).slice(0, 65);
|
|
703
|
+
if (fonts.length > 64)
|
|
704
|
+
throw new Error("A portable backup can include at most 64 uploaded fonts.");
|
|
705
|
+
return fonts.map((font) => {
|
|
706
|
+
archive[fontArchivePath(font.id, font.extension)] = [font.content, { level: 0 }];
|
|
707
|
+
return { id: font.id, extension: font.extension, bytes: font.content.byteLength };
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
function installedUploadedFonts(paths) {
|
|
711
|
+
const root = join(paths.dataDir, "fonts");
|
|
712
|
+
let names;
|
|
713
|
+
try {
|
|
714
|
+
names = readdirSync(root);
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
if (unavailable(error))
|
|
718
|
+
return [];
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
return names.toSorted().flatMap((name) => {
|
|
722
|
+
const match = /^(uploaded-[a-f0-9]{64})(\.ttf|\.otf)$/.exec(name);
|
|
723
|
+
if (!match)
|
|
724
|
+
return [];
|
|
725
|
+
const id = match[1];
|
|
726
|
+
const extension = match[2];
|
|
727
|
+
if (id === undefined || (extension !== ".ttf" && extension !== ".otf"))
|
|
728
|
+
return [];
|
|
729
|
+
const content = readStoredFont(join(root, name));
|
|
730
|
+
return content !== undefined && validFontContent(id, extension, content)
|
|
731
|
+
? [{ id, extension, content }]
|
|
732
|
+
: [];
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
function planFontImport(paths, archive, fonts) {
|
|
736
|
+
const seen = new Set();
|
|
737
|
+
const installed = new Set(installedUploadedFonts(paths).map((font) => font.id));
|
|
738
|
+
return fonts.flatMap((font) => {
|
|
739
|
+
if (seen.has(font.id))
|
|
740
|
+
throw new Error(`The backup repeats uploaded font ${font.id}.`);
|
|
741
|
+
seen.add(font.id);
|
|
742
|
+
const content = archive[fontArchivePath(font.id, font.extension)];
|
|
743
|
+
if (content === undefined ||
|
|
744
|
+
content.byteLength !== font.bytes ||
|
|
745
|
+
!validFontContent(font.id, font.extension, content))
|
|
746
|
+
throw new Error(`The backup has an invalid uploaded font ${font.id}.`);
|
|
747
|
+
if (installed.has(font.id))
|
|
748
|
+
return [];
|
|
749
|
+
const path = join(paths.dataDir, "fonts", `${font.id}${font.extension}`);
|
|
750
|
+
if (existsSync(path))
|
|
751
|
+
throw new Error(`The uploaded font destination ${font.id} is unavailable.`);
|
|
752
|
+
return [{ content, path }];
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
function fontArchivePath(id, extension) {
|
|
756
|
+
return `fonts/${id}${extension}`;
|
|
757
|
+
}
|
|
758
|
+
function validFontContent(id, extension, content) {
|
|
759
|
+
if (content.byteLength < 12 || content.byteLength > fontMaxBytes)
|
|
760
|
+
return false;
|
|
761
|
+
if (`uploaded-${createHash("sha256").update(content).digest("hex")}` !== id)
|
|
762
|
+
return false;
|
|
763
|
+
const metadata = readFontMetadata(content);
|
|
764
|
+
return metadata?.length === 1 && metadata[0]?.extension === extension;
|
|
765
|
+
}
|
|
766
|
+
function readStoredFont(path) {
|
|
767
|
+
let descriptor;
|
|
768
|
+
try {
|
|
769
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
770
|
+
const stat = fstatSync(descriptor);
|
|
771
|
+
if (!stat.isFile() || stat.size < 12 || stat.size > fontMaxBytes)
|
|
772
|
+
return undefined;
|
|
773
|
+
return readFileSync(descriptor);
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
if (unavailable(error))
|
|
777
|
+
return undefined;
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
if (descriptor !== undefined)
|
|
782
|
+
closeSync(descriptor);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function writeImportedFile(deps, path, content, created) {
|
|
786
|
+
const descriptor = openSync(path, "wx", 0o600);
|
|
787
|
+
created.push(path);
|
|
788
|
+
try {
|
|
789
|
+
(deps.writeStagedBytes ?? writeFileSync)(descriptor, content);
|
|
790
|
+
}
|
|
791
|
+
finally {
|
|
792
|
+
closeSync(descriptor);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function unavailable(error) {
|
|
796
|
+
return (error instanceof Error &&
|
|
797
|
+
"code" in error &&
|
|
798
|
+
["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)));
|
|
799
|
+
}
|
|
800
|
+
function planStagedImport(deps, archive, files) {
|
|
801
|
+
const sourceIds = new Set();
|
|
802
|
+
const archivePaths = new Set();
|
|
803
|
+
const destinationIds = new Set();
|
|
804
|
+
return files.map((file) => {
|
|
805
|
+
if (sourceIds.has(file.id))
|
|
806
|
+
throw new Error(`The backup repeats staged file ${file.id}.`);
|
|
807
|
+
if (archivePaths.has(file.archivePath))
|
|
808
|
+
throw new Error(`The backup repeats archive member ${file.archivePath}.`);
|
|
809
|
+
sourceIds.add(file.id);
|
|
810
|
+
archivePaths.add(file.archivePath);
|
|
811
|
+
const content = archive[file.archivePath];
|
|
812
|
+
if (content === undefined)
|
|
813
|
+
throw new Error(`The backup is missing staged file ${file.originalFilename}.`);
|
|
814
|
+
if (content.byteLength !== file.bytes)
|
|
815
|
+
throw new Error(`The backup has an invalid size for staged file ${file.originalFilename}.`);
|
|
816
|
+
const id = deps.ids.next();
|
|
817
|
+
const path = stagingPath(deps.paths, id);
|
|
818
|
+
if (destinationIds.has(id) ||
|
|
819
|
+
existsSync(path) ||
|
|
820
|
+
deps.db.prepare("SELECT 1 FROM staged_files WHERE id=?").get(id) !== undefined)
|
|
821
|
+
throw new Error("The backup could not reserve a unique staged-file destination.");
|
|
822
|
+
destinationIds.add(id);
|
|
823
|
+
return {
|
|
824
|
+
content,
|
|
825
|
+
path,
|
|
826
|
+
row: {
|
|
827
|
+
id,
|
|
828
|
+
stageKind: file.stageKind,
|
|
829
|
+
path: id,
|
|
830
|
+
originalFilename: file.originalFilename,
|
|
831
|
+
bytes: content.byteLength,
|
|
832
|
+
state: "staged",
|
|
833
|
+
createdAt: deps.now(),
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
export function storageBytes(paths) {
|
|
839
|
+
const projects = directoryBytes(paths.projects);
|
|
840
|
+
const staging = directoryBytes(paths.staging);
|
|
841
|
+
return { data: directoryBytes(paths.dataDir), projects, staging };
|
|
842
|
+
}
|
|
843
|
+
export function storageUsage(deps) {
|
|
844
|
+
const totals = storageBytes(deps.paths);
|
|
845
|
+
const byProject = deps.db
|
|
846
|
+
.prepare("SELECT id,title FROM projects ORDER BY created_at DESC, id DESC")
|
|
847
|
+
.all()
|
|
848
|
+
.flatMap((row) => {
|
|
849
|
+
if (typeof row.id !== "string" || typeof row.title !== "string")
|
|
850
|
+
return [];
|
|
851
|
+
return [
|
|
852
|
+
{
|
|
853
|
+
id: row.id,
|
|
854
|
+
title: row.title,
|
|
855
|
+
bytes: directoryBytes(projectDir(deps.paths, row.id)),
|
|
856
|
+
},
|
|
857
|
+
];
|
|
858
|
+
});
|
|
859
|
+
return { ...totals, byProject };
|
|
860
|
+
}
|
|
861
|
+
function directoryBytes(root) {
|
|
862
|
+
if (!existsSync(root))
|
|
863
|
+
return 0;
|
|
864
|
+
let total = 0;
|
|
865
|
+
for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) {
|
|
866
|
+
if (!entry.isFile())
|
|
867
|
+
continue;
|
|
868
|
+
total += statSync(join(entry.parentPath, entry.name)).size;
|
|
869
|
+
}
|
|
870
|
+
return total;
|
|
871
|
+
}
|