Files
DndGamePlayer/app/main/project/zipStore.ts
T
Ivan Fontosh d94a11d466 Редактор: превью с поворотом, проекты, безопасное сохранение zip, dev-меню
RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи.

Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu).

Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip.

Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты.

EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея.
Made-with: Cursor
2026-04-24 07:04:42 +08:00

1417 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fssync from 'node:fs';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { ZipFile } from 'yazl';
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
import type { ScenePatch } from '../../shared/ipc/contracts';
import type {
MediaAsset,
MediaAssetType,
Project,
ProjectId,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanDndZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
import { generateScenePreviewThumbnailBytes } from './scenePreviewThumbnail';
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';
type ProjectIndexEntry = {
id: ProjectId;
name: string;
updatedAt: string;
fileName: string;
};
type OpenProject = {
id: ProjectId;
zipPath: string;
cacheDir: string;
projectPath: string;
project: Project;
};
export class ZipProjectStore {
private openProject: OpenProject | null = null;
private saveQueued = false;
private saving = false;
/** Bumps on create/open so in-flight disk writes cannot commit after project switch. */
private projectSession = 0;
/** Serializes project.json writes — parallel renames caused ENOENT on Windows. */
private projectWriteChain: Promise<void> = Promise.resolve();
/** Пока идёт сборка zip, в кэш не пишем — иначе yauzl/yazl: «unexpected number of bytes». */
private isPacking = false;
private async waitWhilePacking(): Promise<void> {
while (this.isPacking) {
await new Promise((r) => setTimeout(r, 15));
}
}
private async packZipExclusive(cacheDir: string, zipPath: string): Promise<void> {
this.isPacking = true;
try {
await this.packZipFromCache(cacheDir, zipPath);
} finally {
this.isPacking = false;
}
}
async ensureRoots(): Promise<void> {
await fs.mkdir(getProjectsRootDir(), { recursive: true });
await fs.mkdir(getProjectsCacheRootDir(), { recursive: true });
await this.migrateLegacyProjectZipsIfNeeded();
await recoverOrphanDndZipTmpInRoot(getProjectsRootDir());
}
/** Копирует .dnd.zip из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */
private async migrateLegacyProjectZipsIfNeeded(): Promise<void> {
const dest = getProjectsRootDir();
let destNames: string[];
try {
destNames = await fs.readdir(dest);
} catch {
return;
}
const destZips = new Set(destNames.filter((n) => n.endsWith('.dnd.zip')));
for (const legacyRoot of getLegacyProjectsRootDirs()) {
let legacyNames: string[];
try {
legacyNames = await fs.readdir(legacyRoot);
} catch {
continue;
}
for (const name of legacyNames) {
if (!name.endsWith('.dnd.zip')) continue;
if (destZips.has(name)) continue;
const from = path.join(legacyRoot, name);
const to = path.join(dest, name);
try {
const st = await fs.stat(from);
if (!st.isFile()) continue;
// Переносим (а не копируем), чтобы:
// - не было дублей между разными appName
// - удалённые пользователем проекты не «возрождались» при следующем ensureRoots()
try {
await fs.rename(from, to);
} catch {
await fs.copyFile(from, to);
try {
await rmWithRetries(fs.rm, from, { force: true });
} catch {
// best effort: если zip уже скопирован в dest, миграцию считаем успешной;
// legacy-копия может остаться (например из-за lock/AV), но удаление проекта
// затем чистит legacy по fileName.
}
}
destZips.add(name);
} catch {
/* ignore */
}
}
}
}
async listProjects(): Promise<ProjectIndexEntry[]> {
await this.ensureRoots();
const root = getProjectsRootDir();
const entries = await fs.readdir(root, { withFileTypes: true });
const files = entries
.filter((e) => e.isFile() && e.name.endsWith('.dnd.zip'))
.map((e) => path.join(root, e.name));
const out: ProjectIndexEntry[] = [];
for (const filePath of files) {
try {
const project = await readProjectJsonFromZip(filePath);
out.push({
id: project.id,
name: project.meta.name,
updatedAt: project.meta.updatedAt,
fileName: path.basename(filePath),
});
} catch {
// Один битый архив не должен скрывать остальные проекты в списке.
}
}
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return out;
}
async createProject(name: string): Promise<Project> {
await this.ensureRoots();
this.projectSession += 1;
const id = asProjectId(this.randomId());
const now = new Date().toISOString();
const fileBaseName = `${sanitizeFileName(name)}_${id}`;
const appVer = getAppSemanticVersion();
const project: Project = {
id,
meta: {
name,
fileBaseName,
createdAt: now,
updatedAt: now,
createdWithAppVersion: appVer,
appVersion: appVer,
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes: {},
assets: {},
campaignAudios: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
};
const zipPath = path.join(getProjectsRootDir(), `${fileBaseName}.dnd.zip`);
const cacheDir = path.join(getProjectsCacheRootDir(), id);
const projectPath = path.join(cacheDir, 'project.json');
this.openProject = { id, zipPath, cacheDir, projectPath, project };
await this.writeCacheProject(cacheDir, project);
await this.packZipExclusive(cacheDir, zipPath);
return this.openProject.project;
}
async openProjectById(projectId: ProjectId): Promise<Project> {
await this.ensureRoots();
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Project not found');
}
const zipPath = path.join(getProjectsRootDir(), entry.fileName);
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: true });
await unzipToDir(zipPath, cacheDir);
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
const parsed = JSON.parse(projectRaw) as unknown as Project;
const project = normalizeProject(parsed);
const fileBaseName = entry.fileName.replace(/\.dnd\.zip$/iu, '');
project.meta.fileBaseName = project.meta.fileBaseName.trim().length
? project.meta.fileBaseName
: fileBaseName;
this.openProject = { id: projectId, zipPath, cacheDir, projectPath, project };
return project;
}
private async openProjectByIdWithProgress(
projectId: ProjectId,
onUnzipPercent: (pct: number) => void,
): Promise<Project> {
await this.ensureRoots();
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Project not found');
}
const zipPath = path.join(getProjectsRootDir(), entry.fileName);
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: true });
await unzipToDir(zipPath, cacheDir, (done, total) => {
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
onUnzipPercent(Math.max(0, Math.min(100, pct)));
});
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
const parsed = JSON.parse(projectRaw) as unknown as Project;
const project = normalizeProject(parsed);
const fileBaseName = entry.fileName.replace(/\.dnd\.zip$/iu, '');
project.meta.fileBaseName = project.meta.fileBaseName.trim().length
? project.meta.fileBaseName
: fileBaseName;
this.openProject = { id: projectId, zipPath, cacheDir, projectPath, project };
return project;
}
getOpenProject(): Project | null {
return this.openProject?.project ?? null;
}
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
getAssetFileUrl(assetId: AssetId): string | null {
if (!this.getAssetReadInfo(assetId)) return null;
return `dnd://asset?id=${encodeURIComponent(assetId)}`;
}
getAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null {
const open = this.openProject;
if (!open) return null;
const asset = open.project.assets[assetId];
if (!asset) return null;
return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime };
}
getImageAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null {
const open = this.openProject;
if (!open) return null;
const asset = open.project.assets[assetId];
if (asset?.type !== 'image') return null;
return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime };
}
async importScenePreviewMedia(sceneId: SceneId, filePath: string): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const sc = open.project.scenes[sceneId];
if (!sc) throw new Error('Scene not found');
const kind0 = classifyMediaPath(filePath);
if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
throw new Error('Файл превью должен быть изображением или видео');
}
let kind: MediaKind = kind0;
const buf = await fs.readFile(filePath);
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
let safeOrig = sanitizeFileName(orig);
let relPath = `assets/${id}_${safeOrig}`;
let abs = path.join(open.cacheDir, relPath);
let writeBuf = buf;
let storedOrig = orig;
if (kind.type === 'image') {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (!opt.passthrough) {
writeBuf = Buffer.from(opt.buffer);
kind = { type: 'image', mime: opt.mime };
safeOrig = sanitizeFileName(`${path.parse(orig).name}.${opt.ext}`);
relPath = `assets/${id}_${safeOrig}`;
abs = path.join(open.cacheDir, relPath);
storedOrig = `${path.parse(orig).name}.${opt.ext}`;
}
}
const sha256 = crypto.createHash('sha256').update(writeBuf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, writeBuf);
const asset = buildMediaAsset(id, kind, storedOrig, relPath, sha256, writeBuf.length);
const thumbKind = kind.type === 'image' ? 'image' : 'video';
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, thumbKind);
let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(open.cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes);
const thumbSha = crypto.createHash('sha256').update(thumbBytes).digest('hex');
const thumbOrigName = `${path.parse(safeOrig).name}_preview_thumb.webp`;
thumbAsset = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
thumbOrigName,
thumbRelPath,
thumbSha,
thumbBytes.length,
);
}
const oldPreviewId = sc.previewAssetId;
const oldThumbId = sc.previewThumbAssetId ?? null;
await this.updateProject((p) => {
const scene = p.scenes[sceneId];
if (!scene) throw new Error('Scene not found');
let assets: Record<AssetId, MediaAsset> = { ...p.assets };
const drop = new Set<AssetId>();
if (oldPreviewId) drop.add(oldPreviewId);
if (oldThumbId) drop.add(oldThumbId);
if (drop.size > 0) {
assets = Object.fromEntries(
Object.entries(assets).filter(([k]) => !drop.has(k as AssetId)),
) as Record<AssetId, MediaAsset>;
}
assets[id] = asset;
if (thumbAsset !== null && thumbId !== null) {
assets[thumbId] = thumbAsset;
}
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: id,
previewAssetType: kind.type,
previewThumbAssetId: thumbId,
previewVideoAutostart: kind.type === 'video' ? scene.previewVideoAutostart : false,
},
},
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async clearScenePreview(sceneId: SceneId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const sc = open.project.scenes[sceneId];
if (!sc) throw new Error('Scene not found');
const oldId = sc.previewAssetId;
const oldThumbId = sc.previewThumbAssetId ?? null;
if (!oldId && !oldThumbId) {
return open.project;
}
await this.updateProject((p) => {
const drop = new Set<AssetId>();
if (oldId) drop.add(oldId);
if (oldThumbId) drop.add(oldThumbId);
const assets = Object.fromEntries(
Object.entries(p.assets).filter(([k]) => !drop.has(k as AssetId)),
) as Record<AssetId, MediaAsset>;
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...p.scenes[sceneId],
previewAssetId: null,
previewAssetType: null,
previewThumbAssetId: null,
previewVideoAutostart: false,
},
},
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateProject(mutator: (draft: Project) => Project): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const prev = open.project;
let next = mutator(prev);
next = await reconcileAssetFiles(prev, next, open.cacheDir);
open.project = next;
await this.writeCacheProject(open.cacheDir, next);
this.queueSave();
return next;
}
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const existing = open.project.scenes[sceneId];
const base: Scene =
existing ??
({
id: sceneId,
title: '',
description: '',
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewThumbAssetId: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
} satisfies Scene);
const next: Scene = {
...base,
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
: null),
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
};
await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } }));
return next;
}
async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const existing = open.project.scenes[sceneId];
if (!existing) throw new Error('Scene not found');
const next: Scene = { ...existing, connections: [...connections] };
await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } }));
return next;
}
async deleteScene(sceneId: SceneId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (!open.project.scenes[sceneId]) throw new Error('Scene not found');
await this.updateProject((p) => {
const sceneGraphNodes = p.sceneGraphNodes.filter((n) => n.sceneId !== sceneId);
const nodeIds = new Set(sceneGraphNodes.map((n) => n.id));
const sceneGraphEdges = p.sceneGraphEdges.filter(
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
);
const scenes = Object.fromEntries(
(Object.entries(p.scenes) as [SceneId, Scene][]).filter(([id]) => id !== sceneId),
) as Record<SceneId, Scene>;
const withGraph: Project = {
...p,
scenes,
sceneGraphNodes,
sceneGraphEdges,
};
const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
const nextScenes = applyConnectionSets(withGraph.scenes, outgoing);
let currentSceneId = p.currentSceneId;
if (currentSceneId === sceneId) {
const ids = Object.keys(nextScenes) as SceneId[];
currentSceneId = ids[0] ?? null;
}
return {
...withGraph,
scenes: nextScenes,
currentSceneId,
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateSceneGraphNodePosition(nodeId: GraphNodeId, x: number, y: number): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async addSceneGraphNode(sceneId: SceneId, x: number, y: number): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (!open.project.scenes[sceneId]) throw new Error('Scene not found');
const node: SceneGraphNode = {
id: asGraphNodeId(`gn_${this.randomId()}`),
sceneId,
x,
y,
isStartScene: false,
};
await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] }));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setSceneGraphNodeStart(graphNodeId: GraphNodeId | null): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
throw new Error('Graph node not found');
}
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => ({
...n,
isStartScene: graphNodeId !== null && n.id === graphNodeId,
})),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async removeSceneGraphNode(nodeId: GraphNodeId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const nextNodes = open.project.sceneGraphNodes.filter((gn) => gn.id !== nodeId);
const nextEdges = open.project.sceneGraphEdges.filter(
(e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId,
);
await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async addSceneGraphEdge(sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const { sceneGraphNodes, sceneGraphEdges } = open.project;
if (isSceneGraphEdgeRejected(sceneGraphNodes, sceneGraphEdges, sourceGraphNodeId, targetGraphNodeId)) {
return open.project;
}
const edge: SceneGraphEdge = {
id: `e_${this.randomId()}`,
sourceGraphNodeId,
targetGraphNodeId,
};
await this.updateProject((p) => {
const nextEdges = [...p.sceneGraphEdges, edge];
const withGraph = { ...p, sceneGraphEdges: nextEdges };
const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async removeSceneGraphEdge(edgeId: string): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const nextEdges = p.sceneGraphEdges.filter((e) => e.id !== edgeId);
const withGraph = { ...p, sceneGraphEdges: nextEdges };
const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
/**
* Copies files into cache `assets/` and registers them on the scene + project.assets.
*/
async importMediaFiles(
sceneId: SceneId,
filePaths: string[],
): Promise<{ project: Project; imported: MediaAsset[] }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const existingScene = open.project.scenes[sceneId];
if (!existingScene) throw new Error('Scene not found');
if (filePaths.length === 0) {
return { project: open.project, imported: [] };
}
const staged: MediaAsset[] = [];
for (const filePath of filePaths) {
const kind = classifyMediaPath(filePath);
if (kind?.type !== 'audio') continue;
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
}
if (staged.length === 0) {
return { project: open.project, imported: [] };
}
await this.updateProject((p) => {
const sc = p.scenes[sceneId];
if (!sc) throw new Error('Scene not found');
const assets = { ...p.assets };
const media = {
videos: [...sc.media.videos],
audios: [...sc.media.audios],
};
for (const asset of staged) {
assets[asset.id] = asset;
if (asset.type === 'video') media.videos.push(asset.id);
else
media.audios.push({
assetId: asset.id,
autoplay: sc.settings.autoplayAudio,
loop: sc.settings.loopAudio,
});
}
return {
...p,
assets,
scenes: { ...p.scenes, [sceneId]: { ...sc, media } },
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, imported: staged };
}
/**
* Copies audio files into cache `assets/` and registers them on the project as campaign audio.
*/
async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (filePaths.length === 0) {
return { project: open.project, imported: [] };
}
const staged: MediaAsset[] = [];
for (const filePath of filePaths) {
const kind = classifyMediaPath(filePath);
if (kind?.type !== 'audio') continue;
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
}
if (staged.length === 0) {
return { project: open.project, imported: [] };
}
await this.updateProject((p) => {
const assets = { ...p.assets };
const campaignAudios = [...p.campaignAudios];
for (const asset of staged) {
assets[asset.id] = asset;
if (asset.type !== 'audio') continue;
campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true });
}
return { ...p, assets, campaignAudios };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, imported: staged };
}
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
}
async renameOpenProject(name: string, fileBaseName: string): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const nextName = name.trim();
const nextBase = fileBaseName.trim();
if (nextName.length < 3) {
throw new Error('Название проекта должно быть не короче 3 символов');
}
if (nextBase.length < 3) {
throw new Error('Название файла проекта должно быть не короче 3 символов');
}
const sanitizedBase = sanitizeFileName(nextBase);
if (sanitizedBase !== nextBase) {
throw new Error('Название файла содержит недопустимые символы');
}
await this.ensureRoots();
const root = getProjectsRootDir();
const list = await this.listProjects();
const oldBase = open.project.meta.fileBaseName;
const nextFileName = `${sanitizedBase}.dnd.zip`;
const nameClash = list.some(
(p) => p.id !== open.id && p.name.trim().toLowerCase() === nextName.toLowerCase(),
);
if (nameClash) {
throw new Error('Проект с таким названием уже существует');
}
const fileClash = list.some(
(p) => p.id !== open.id && p.fileName.trim().toLowerCase() === nextFileName.toLowerCase(),
);
if (fileClash) {
throw new Error('Файл проекта с таким названием уже существует');
}
// Update project meta first (will auto-save).
await this.updateProject((p) => ({
...p,
meta: { ...p.meta, name: nextName, fileBaseName: sanitizedBase },
}));
// Rename zip on disk (cache stays the same, just points to new zip path).
if (nextBase !== oldBase) {
const nextZipPath = path.join(root, nextFileName);
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
await replaceFileAtomic(open.zipPath, nextZipPath);
open.zipPath = nextZipPath;
}
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
private queueSave() {
if (this.saveQueued) return;
this.saveQueued = true;
setTimeout(() => void this.flushSave(), 250);
}
private async flushSave() {
if (this.saving) return;
const open = this.openProject;
if (!open) return;
this.saveQueued = false;
this.saving = true;
try {
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
} finally {
this.saving = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async save
if (this.saveQueued) {
setTimeout(() => void this.flushSave(), 50);
}
}
}
private async writeCacheProject(cacheDir: string, project: Project): Promise<void> {
const sessionAtStart = this.projectSession;
const run = async (): Promise<void> => {
await this.waitWhilePacking();
if (sessionAtStart !== this.projectSession) {
return;
}
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
const now = new Date().toISOString();
const withUpdated: Project = {
...project,
meta: {
...project.meta,
updatedAt: now,
appVersion: getAppSemanticVersion(),
},
};
const targetPath = path.join(cacheDir, 'project.json');
await atomicWriteFile(targetPath, JSON.stringify(withUpdated, null, 2));
if (sessionAtStart !== this.projectSession) {
return;
}
if (this.openProject?.cacheDir === cacheDir) {
this.openProject.project = withUpdated;
}
};
const chained = this.projectWriteChain.then(run);
this.projectWriteChain = chained.catch(() => undefined);
await chained;
}
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
const tmpPath = `${zipPath}.tmp`;
await fs.mkdir(path.dirname(zipPath), { recursive: true });
await zipDir(cacheDir, tmpPath);
const st = await fs.stat(tmpPath).catch(() => null);
if (!st?.isFile() || st.size < 22) {
await fs.unlink(tmpPath).catch(() => undefined);
throw new Error('Сборка архива проекта не удалась (пустой или повреждённый временный файл)');
}
await replaceFileAtomic(tmpPath, zipPath);
}
/**
* Копирует внешний `.dnd.zip` в каталог проектов и открывает его.
* Если архив уже лежит в `projects`, только открывает.
* При конфликте `id` с другим файлом перезаписывает `project.json` в копии с новым id.
*/
async importProjectFromExternalZip(
sourcePath: string,
onProgress?: (p: { stage: 'copy' | 'unzip' | 'done'; percent: number; detail?: string }) => void,
): Promise<Project> {
await this.ensureRoots();
const resolved = path.resolve(sourcePath);
const st = await fs.stat(resolved).catch(() => null);
if (!st?.isFile()) {
throw new Error('Файл проекта не найден');
}
if (!resolved.toLowerCase().endsWith('.dnd.zip')) {
throw new Error('Ожидается файл с расширением .dnd.zip');
}
const root = getProjectsRootDir();
const rootNorm = path.normalize(root).toLowerCase();
const dirNorm = path.normalize(path.dirname(resolved)).toLowerCase();
const baseName = path.basename(resolved);
let destPath: string;
let destFileName: string;
if (dirNorm === rootNorm && baseName.toLowerCase().endsWith('.dnd.zip')) {
destPath = resolved;
destFileName = baseName;
} else {
destFileName = await uniqueDndZipFileName(root, baseName);
destPath = path.join(root, destFileName);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(resolved, destPath, (pct) => {
if (!onProgress) return;
// Copy is ~70% of the operation; unzip/open happens after.
onProgress({ stage: 'copy', percent: Math.max(1, Math.min(70, pct)), detail: 'Копирование…' });
});
}
let project = await readProjectJsonFromZip(destPath);
project = normalizeProject(project);
const entries = await this.listProjects();
const othersWithSameId = entries.filter((e) => e.id === project.id && e.fileName !== destFileName);
if (othersWithSameId.length > 0) {
const newId = asProjectId(this.randomId());
const stem = destFileName.replace(/\.dnd\.zip$/iu, '');
project = {
...project,
id: newId,
meta: {
...project.meta,
fileBaseName: project.meta.fileBaseName.trim().length ? project.meta.fileBaseName : stem,
},
};
await rewriteProjectJsonInZip(destPath, project);
}
this.projectSession += 1;
const opened = await this.openProjectByIdWithProgress(project.id, (pct) => {
if (onProgress) onProgress({ stage: 'unzip', percent: pct, detail: 'Распаковка…' });
});
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
return opened;
}
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
async exportProjectZipToPath(
projectId: ProjectId,
destinationPath: string,
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<void> {
await this.ensureRoots();
// If exporting the currently open project, make sure pending debounced pack is flushed.
if (this.openProject?.id === projectId) {
await this.saveNow();
}
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const src = path.join(getProjectsRootDir(), entry.fileName);
const dest = path.resolve(destinationPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(src, dest, (pct) => {
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
});
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
}
/** Удаляет архив проекта и кэш распаковки с диска. Если проект открыт — сбрасывает сессию. */
async deleteProjectById(projectId: ProjectId): Promise<void> {
await this.ensureRoots();
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const zipPath = path.join(getProjectsRootDir(), entry.fileName);
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
if (this.openProject?.id === projectId) {
await this.waitWhilePacking();
await this.projectWriteChain;
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;
}
await rmWithRetries(fs.rm, zipPath, { force: true });
await rmWithRetries(fs.rm, cacheDir, { recursive: true, force: true });
// Если проект подтянулся миграцией из legacy userData (другое имя приложения),
// то после удаления из текущей папки он может снова появиться при следующем ensureRoots().
// Поэтому удаляем и legacy-копии архива.
for (const legacyRoot of getLegacyProjectsRootDirs()) {
const legacyZipPath = path.join(legacyRoot, entry.fileName);
try {
await rmWithRetries(fs.rm, legacyZipPath, { force: true });
} catch {
/* ignore */
}
}
}
private randomId(): string {
return crypto.randomBytes(16).toString('hex');
}
// id is stored in project.json inside the zip
}
function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
const gnMap = new Map(nodes.map((n) => [n.id, n]));
const outgoing = new Map<SceneId, Set<SceneId>>();
for (const e of edges) {
const a = gnMap.get(e.sourceGraphNodeId);
const b = gnMap.get(e.targetGraphNodeId);
if (!a || !b) continue;
if (a.sceneId === b.sceneId) continue;
let set = outgoing.get(a.sceneId);
if (!set) {
set = new Set();
outgoing.set(a.sceneId, set);
}
set.add(b.sceneId);
}
return outgoing;
}
function applyConnectionSets(
scenes: Record<SceneId, Scene>,
outgoing: Map<SceneId, Set<SceneId>>,
): Record<SceneId, Scene> {
const next: Record<SceneId, Scene> = { ...scenes };
for (const sid of Object.keys(next) as SceneId[]) {
const prev = next[sid];
if (prev === undefined) continue;
const c = outgoing.get(sid);
next[sid] = { ...prev, connections: c ? [...c] : [] };
}
return next;
}
function migrateSceneGraphFromLegacy(scenes: Record<SceneId, Scene>): {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
} {
const sceneList = Object.values(scenes);
const sceneGraphNodes: SceneGraphNode[] = sceneList.map((s) => ({
id: asGraphNodeId(`gn_legacy_${s.id}`),
sceneId: s.id,
x: s.layout.x,
y: s.layout.y,
isStartScene: false,
}));
const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n]));
const sceneGraphEdges: SceneGraphEdge[] = [];
for (const s of sceneList) {
const srcGn = byScene.get(s.id);
if (!srcGn) continue;
for (const to of s.connections) {
const tgtGn = byScene.get(to);
if (!tgtGn) continue;
if (srcGn.sceneId === tgtGn.sceneId) continue;
sceneGraphEdges.push({
id: `e_legacy_${String(srcGn.id)}_${String(tgtGn.id)}`,
sourceGraphNodeId: srcGn.id,
targetGraphNodeId: tgtGn.id,
});
}
}
return { sceneGraphNodes, sceneGraphEdges };
}
/** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */
function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] {
const withDefaults = nodes.map((n) => {
const raw = n as unknown as { isStartScene?: boolean };
return {
...n,
isStartScene: raw.isStartScene === true,
};
});
const starters = withDefaults.filter((n) => n.isStartScene);
if (starters.length <= 1) {
return withDefaults;
}
const keep = starters[0];
if (!keep) return withDefaults;
const keepId = keep.id;
return withDefaults.map((n) => ({ ...n, isStartScene: n.id === keepId }));
}
function normalizeScene(s: Scene): Scene {
const raw = s.media as unknown as { videos?: AssetId[]; audios?: unknown[] };
const layoutIn = (s as { layout?: Scene['layout'] }).layout;
const rot = (s as unknown as { previewRotationDeg?: number }).previewRotationDeg;
const previewRotationDeg: Scene['previewRotationDeg'] = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
const legacyPreviewId =
(s as unknown as { previewImageAssetId?: AssetId | null }).previewImageAssetId ?? null;
const previewAssetId =
(s as unknown as { previewAssetId?: AssetId | null }).previewAssetId ?? legacyPreviewId;
const previewAssetType =
(s as unknown as { previewAssetType?: 'image' | 'video' | null }).previewAssetType ??
(legacyPreviewId ? 'image' : null);
const previewVideoAutostart = Boolean(
(s as unknown as { previewVideoAutostostart?: boolean; previewVideoAutostart?: boolean })
.previewVideoAutostart,
);
const previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
const audios = rawAudios
.map((a) => {
if (typeof a === 'string') {
return {
assetId: a as AssetId,
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
};
}
if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
}
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
return {
...s,
previewAssetId: previewAssetId ?? null,
previewAssetType,
previewThumbAssetId,
previewVideoAutostart,
previewRotationDeg,
layout: layoutIn ?? { x: 0, y: 0 },
media: {
videos: raw.videos ?? [],
audios,
},
};
}
function normalizeProject(p: Project): Project {
const scenes: Record<SceneId, Scene> = {};
for (const sid of Object.keys(p.scenes) as SceneId[]) {
const rawScene = p.scenes[sid];
if (!rawScene) continue;
scenes[sid] = normalizeScene(rawScene);
}
const schemaVersion = (p.meta as { schemaVersion?: number }).schemaVersion ?? 1;
const rawNodes = (p as { sceneGraphNodes?: SceneGraphNode[] }).sceneGraphNodes;
const rawEdges = (p as { sceneGraphEdges?: SceneGraphEdge[] }).sceneGraphEdges;
let sceneGraphNodes: SceneGraphNode[] = Array.isArray(rawNodes) ? rawNodes : [];
let sceneGraphEdges: SceneGraphEdge[] = Array.isArray(rawEdges) ? rawEdges : [];
const needsLegacyGraphMigration = schemaVersion < PROJECT_SCHEMA_VERSION && sceneGraphNodes.length === 0;
if (needsLegacyGraphMigration) {
const migrated = migrateSceneGraphFromLegacy(scenes);
sceneGraphNodes = migrated.sceneGraphNodes;
sceneGraphEdges = migrated.sceneGraphEdges;
}
sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes);
const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null;
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
.map((a) => {
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
}
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
const createdWithAppVersion = (() => {
const c = metaRaw.createdWithAppVersion?.trim();
if (c && c.length > 0) return c;
const a = metaRaw.appVersion?.trim();
if (a && a.length > 0) return a;
return '0.0.0';
})();
return {
...p,
meta: {
...p.meta,
fileBaseName: (p.meta as unknown as { fileBaseName?: string }).fileBaseName?.trim().length
? (p.meta as unknown as { fileBaseName: string }).fileBaseName
: '',
createdWithAppVersion,
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
campaignAudios,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,
};
}
function sanitizeFileName(name: string): string {
const trimmed = name.trim();
const safe = trimmed.replace(/[<>:"/\\|?*]/gu, '_');
return safe.length > 0 ? safe : 'Untitled';
}
async function uniqueDndZipFileName(root: string, preferredBaseFileName: string): Promise<string> {
let base = preferredBaseFileName;
if (!base.toLowerCase().endsWith('.dnd.zip')) {
const stem = sanitizeFileName(path.basename(base, path.extname(base)) || 'project');
base = `${stem}.dnd.zip`;
}
const stem = base.replace(/\.dnd\.zip$/iu, '');
let candidate = base;
let n = 0;
for (;;) {
try {
await fs.access(path.join(root, candidate));
n += 1;
candidate = `${stem}_${String(n)}.dnd.zip`;
} catch {
return candidate;
}
}
}
async function copyFileWithProgress(
src: string,
dest: string,
onPercent: (pct: number) => void,
): Promise<void> {
const st = await fs.stat(src);
const total = st.size || 0;
if (total <= 0) {
await fs.copyFile(src, dest);
onPercent(100);
return;
}
await fs.mkdir(path.dirname(dest), { recursive: true });
await new Promise<void>((resolve, reject) => {
let done = 0;
const rs = fssync.createReadStream(src);
const ws = fssync.createWriteStream(dest);
const onErr = (e: unknown) => reject(e instanceof Error ? e : new Error(String(e)));
rs.on('error', onErr);
ws.on('error', onErr);
rs.on('data', (chunk: Buffer) => {
done += chunk.length;
const pct = Math.round((done / total) * 100);
try {
onPercent(Math.max(0, Math.min(100, pct)));
} catch {
// ignore
}
});
ws.on('close', () => resolve());
rs.pipe(ws);
});
}
type MediaKind = { type: MediaAssetType; mime: string };
function classifyMediaPath(filePath: string): MediaKind | null {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.png':
return { type: 'image', mime: 'image/png' };
case '.jpg':
case '.jpeg':
return { type: 'image', mime: 'image/jpeg' };
case '.webp':
return { type: 'image', mime: 'image/webp' };
case '.gif':
return { type: 'image', mime: 'image/gif' };
case '.bmp':
return { type: 'image', mime: 'image/bmp' };
case '.mp4':
return { type: 'video', mime: 'video/mp4' };
case '.webm':
return { type: 'video', mime: 'video/webm' };
case '.mov':
return { type: 'video', mime: 'video/quicktime' };
case '.mp3':
return { type: 'audio', mime: 'audio/mpeg' };
case '.wav':
return { type: 'audio', mime: 'audio/wav' };
case '.ogg':
return { type: 'audio', mime: 'audio/ogg' };
case '.m4a':
return { type: 'audio', mime: 'audio/mp4' };
case '.aac':
return { type: 'audio', mime: 'audio/aac' };
default:
return null;
}
}
function buildMediaAsset(
id: AssetId,
kind: MediaKind,
originalName: string,
relPath: string,
sha256: string,
sizeBytes: number,
): MediaAsset {
const createdAt = new Date().toISOString();
const base = {
id,
mime: kind.mime,
originalName,
relPath,
sha256,
sizeBytes,
createdAt,
};
if (kind.type === 'image') return { ...base, type: 'image' };
if (kind.type === 'video') return { ...base, type: 'video' };
return { ...base, type: 'audio' };
}
async function atomicWriteFile(filePath: string, contents: string): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmp = path.join(dir, `.tmp_${path.basename(filePath)}_${crypto.randomBytes(8).toString('hex')}`);
await fs.writeFile(tmp, contents, 'utf8');
await replaceFileAtomic(tmp, filePath);
}
/** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */
function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } {
const norm = rel.replace(/\\/gu, '/').toLowerCase();
if (norm === 'project.json') {
return { compressionLevel: 9 };
}
if (norm.startsWith('assets/')) {
const ext = path.extname(norm).toLowerCase();
if (
ext === '.jpg' ||
ext === '.jpeg' ||
ext === '.mp4' ||
ext === '.webm' ||
ext === '.mov' ||
ext === '.mp3' ||
ext === '.m4a' ||
ext === '.aac' ||
ext === '.ogg' ||
ext === '.webp' ||
ext === '.gif'
) {
return { compressionLevel: 0 };
}
}
return { compressionLevel: 9 };
}
async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
const zipfile = new ZipFile();
const all = await listFilesRecursive(srcDir);
for (const abs of all) {
const rel = path.relative(srcDir, abs).replace(/\\/gu, '/');
zipfile.addFile(abs, rel, zipOptionsForRelativeEntry(rel));
}
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
const out = fssync.createWriteStream(outZipPath);
const done = new Promise<void>((resolve, reject) => {
out.on('close', resolve);
out.on('error', reject);
});
zipfile.outputStream.pipe(out);
zipfile.end();
await done;
}
async function rewriteProjectJsonInZip(zipPath: string, project: Project): Promise<void> {
const work = path.join(tmpdir(), `dnd_rewrite_${crypto.randomBytes(8).toString('hex')}`);
await fs.mkdir(work, { recursive: true });
try {
await unzipToDir(zipPath, work);
await atomicWriteFile(path.join(work, 'project.json'), JSON.stringify(project, null, 2));
const outTmp = `${zipPath}.rewrite.tmp`;
await zipDir(work, outTmp);
await replaceFileAtomic(outTmp, zipPath);
} finally {
await fs.rm(work, { recursive: true, force: true });
}
}
async function listFilesRecursive(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const out: string[] = [];
for (const e of entries) {
if (e.name.startsWith('.tmp_')) {
continue;
}
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await listFilesRecursive(abs)));
} else if (e.isFile()) {
out.push(abs);
}
}
return out;
}