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/core/managers/project-manager.ts | 57 +++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) (limited to 'apps/web/src/core/managers') diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index b38fd50d..7755ab0f 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -8,6 +8,12 @@ import type { TTimelineViewState, } from "@/project/types"; import type { ExportOptions, ExportResult, ExportState } from "@/export"; +import { + ProjectArchiveError, + exportProjectArchive, + importProjectArchive, +} from "@/project/transfer"; +import { downloadBlob } from "@/utils/browser"; import { storageService } from "@/services/storage/service"; import { toast } from "sonner"; import { generateUUID } from "@/utils/id"; @@ -283,6 +289,7 @@ export class ProjectManager { Promise.all([ storageService.deleteProjectMedia({ projectId: id }), storageService.deleteProject({ id }), + this.editor.versions.deleteVersionsForProject({ projectId: id }), ]), ), ); @@ -704,4 +711,54 @@ export class ProjectManager { fn(); }); } + + async exportArchive({ id }: { id: string }): Promise { + try { + if (this.active?.metadata.id === id) { + await this.editor.save.flush(); + } + const { blob, fileName, missingMediaIds } = await exportProjectArchive({ + projectId: id, + }); + if (missingMediaIds.length > 0) { + toast.warning( + missingMediaIds.length === 1 + ? "1 media file is missing from storage and was not included in the export" + : `${missingMediaIds.length} media files are missing from storage and were not included in the export`, + ); + } + downloadBlob({ blob, filename: fileName }); + } catch (error) { + console.error("Failed to export project:", error); + toast.error("Failed to export project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + } + + async importArchive({ file }: { file: File }): Promise { + try { + const { project } = await importProjectArchive({ + file, + existingNames: this.savedProjects.map((saved) => saved.name), + }); + this.updateMetadata(project); + toast.success(`Imported "${project.metadata.name}"`); + return project.metadata.id; + } catch (error) { + console.error("Failed to import project:", error); + if (error instanceof ProjectArchiveError) { + toast.error("Couldn't import project", { + description: error.message, + }); + } else { + toast.error("Failed to import project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + return null; + } + } } -- cgit v1.3.1