summaryrefslogtreecommitdiff
path: root/apps/web/src/project/transfer/migrate.ts
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
committercashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
commit30a36e45686db503fded68e4da5d4c465a06f978 (patch)
tree2dbe5e2aaef543155b021f5f4c504fad77b40648 /apps/web/src/project/transfer/migrate.ts
parentd55d2583a71ae35fa8d0d1bfbec975e67e66aafa (diff)
downloadopencut-30a36e45686db503fded68e4da5d4c465a06f978.tar.gz
feat: single-file project import/export (.ocp)
fflate zip container: project.json (serialized project incl. schema version) + media files with a manifest. Export gathers all project media (missing blobs warn, never abort) after save-flush; import validates everything before any write, migrates older archives through the storage migration registry, remaps media ids, and creates a new project with rollback on failure. Entry points: projects page import/export, editor header menu, export-project-archive action. 29 tests incl. full round-trip.
Diffstat (limited to 'apps/web/src/project/transfer/migrate.ts')
-rw-r--r--apps/web/src/project/transfer/migrate.ts75
1 files changed, 75 insertions, 0 deletions
diff --git a/apps/web/src/project/transfer/migrate.ts b/apps/web/src/project/transfer/migrate.ts
new file mode 100644
index 00000000..14f59790
--- /dev/null
+++ b/apps/web/src/project/transfer/migrate.ts
@@ -0,0 +1,75 @@
+import type { StorageMigration } from "@/services/storage/migrations/base";
+import type { ProjectRecord } from "@/services/storage/migrations/transformers/types";
+import { getProjectId } from "@/services/storage/migrations/transformers/utils";
+
+/**
+ * In-memory counterpart to `runStorageMigrations`: applies the same migration
+ * registry to a single project record (e.g. parsed from an imported project
+ * archive) without touching IndexedDB.
+ */
+
+export function getRecordVersion({
+ project,
+}: {
+ project: ProjectRecord;
+}): number {
+ const versionValue = project.version;
+ if (typeof versionValue === "number") {
+ return versionValue;
+ }
+
+ const scenesValue = project.scenes;
+ if (Array.isArray(scenesValue) && scenesValue.length > 0) {
+ return 1;
+ }
+
+ return 0;
+}
+
+export interface MigratedProjectRecord {
+ project: ProjectRecord;
+ fromVersion: number;
+ toVersion: number;
+ complete: boolean;
+}
+
+export async function migrateProjectRecord({
+ project,
+ migrations,
+}: {
+ project: ProjectRecord;
+ migrations: StorageMigration[];
+}): Promise<MigratedProjectRecord> {
+ const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from);
+ const fromVersion = getRecordVersion({ project });
+ const targetVersion = orderedMigrations.at(-1)?.to ?? fromVersion;
+
+ let currentVersion = fromVersion;
+ let record = project;
+ const projectId = getProjectId({ project: record }) ?? "imported-project";
+
+ for (const migration of orderedMigrations) {
+ if (migration.from !== currentVersion) {
+ continue;
+ }
+
+ const result = await migration.run({
+ projectId,
+ project: record,
+ });
+
+ if (result.skipped) {
+ break;
+ }
+
+ record = result.project;
+ currentVersion = migration.to;
+ }
+
+ return {
+ project: record,
+ fromVersion,
+ toVersion: currentVersion,
+ complete: currentVersion >= targetVersion,
+ };
+}