From 30a36e45686db503fded68e4da5d4c465a06f978 Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:20 +0200 Subject: 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. --- apps/web/src/project/transfer/migrate.ts | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/web/src/project/transfer/migrate.ts (limited to 'apps/web/src/project/transfer/migrate.ts') 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 { + 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, + }; +} -- cgit v1.3.1