feat(editor): побочные сюжетные линии и импорт/экспорт линий

Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован partial export/import сюжетных линий:
- экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json;
- импорт в открытый проект с выбором линий, разрешением конфликтов названий
  сцен (одна модалка на операцию) и отчётом о результате;
- новое окно источника импорта: «Из проекта» (dropdown, без текущего) или
  «Из файла»; на главном экране — только полный импорт из файла.

Исправлены гонки при открытии/закрытии проекта (сериализация open/close в
main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и
старт Electron в dev после готовности Vite.

Добавлены unit-тесты для lineage, export/import и контрактов zipStore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-09 14:47:28 +08:00
parent de9190959c
commit c8ab9dd567
26 changed files with 3507 additions and 266 deletions
+114 -12
View File
@@ -517,6 +517,16 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setSceneGraphNodeSideStoryStart, async ({ graphNodeId }) => {
const project = await projectStore.setSceneGraphNodeSideStoryStart(graphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateSideStoryLineTitle, async ({ graphNodeId, title }) => {
const project = await projectStore.updateSideStoryLineTitle(graphNodeId, title);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
const project = await projectStore.deleteScene(sceneId);
emitSessionState();
@@ -537,7 +547,6 @@ async function main() {
}
const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
// Let store import; progress for unzip is emitted from unzipToDir wrapper in store.
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({
kind: 'import',
@@ -550,7 +559,94 @@ async function main() {
emitSessionState();
return { canceled: false as const, project };
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId }) => {
registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
const storylines = await projectStore.getProjectStorylines(projectId, labels);
return { storylines };
});
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const filePath = filePaths[0];
const peek = await projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
return { canceled: false as const, ...peek };
});
registerHandler(ipcChannels.project.pickImportZipFile, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
return { canceled: false as const, filePath: filePaths[0] };
});
registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.mergeImportZip, async ({ filePath, storylineSelections, sceneResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
filePath,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
});
registerHandler(
ipcChannels.project.mergeImportFromProject,
async ({ sourceProjectId, storylineSelections, sceneResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromProjectId(
sourceProjectId,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
},
);
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
@@ -569,17 +665,23 @@ async function main() {
return { canceled: true as const };
}
const dest = normalizeSaveProjectZipPath(filePath);
emitZipProgress({ kind: 'export', stage: 'copy', percent: 0, detail: 'Экспорт…' });
await projectStore.exportProjectZipToPath(projectId, dest, (p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
});
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err;
}
});
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId);
@@ -44,9 +44,16 @@ void test('zipStore: openProjectById skips re-unzip when project is already open
void test('zipStore: pack and open operations are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private packChain: Promise<void>/);
assert.match(src, /private openChain: Promise<void>/);
assert.match(src, /private projectSwitchChain: Promise<void>/);
assert.match(src, /enqueuePack/);
assert.match(src, /enqueueOpenProject/);
assert.match(src, /enqueueProjectSwitch/);
});
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
assert.match(src, /Открытие проекта отменено/);
});
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
+413 -17
View File
@@ -7,6 +7,20 @@ import path from 'node:path';
import { ZipFile } from 'yazl';
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage';
import {
buildPartialExportProject,
computeGraphImportOffsetX,
listExportableStorylines,
listImportableStorylines,
mergeStorylinesIntoProject,
newExportBundleProjectId,
type SceneImportResolution,
type StorylineImportMergeReport,
type StorylineLabels,
type StorylineListItem,
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { ScenePatch } from '../../shared/ipc/contracts';
import {
isProjectZipFileName,
@@ -61,8 +75,8 @@ export class ZipProjectStore {
private projectWriteChain: Promise<void> = Promise.resolve();
/** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
private packChain: Promise<void> = Promise.resolve();
/** Serializes open/unzip — double-click fired two concurrent opens and corrupted reads. */
private openChain: Promise<void> = Promise.resolve();
/** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */
private projectSwitchChain: Promise<void> = Promise.resolve();
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
@@ -73,15 +87,19 @@ export class ZipProjectStore {
return next;
}
private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
const task = this.openChain.then(() => this.openProjectByIdInner(projectId, onUnzipPercent));
this.openChain = task.then(
private enqueueProjectSwitch<T>(fn: () => Promise<T>): Promise<T> {
const task = this.projectSwitchChain.then(() => fn());
this.projectSwitchChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
return this.enqueueProjectSwitch(() => this.openProjectByIdInner(projectId, onUnzipPercent));
}
/** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */
private async drainSavePipeline(): Promise<void> {
if (this.saveDebounceTimer) {
@@ -228,13 +246,18 @@ export class ZipProjectStore {
if (this.openProject?.id === projectId) {
return this.openProject.project;
}
const sessionAtStart = this.projectSession;
// 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();
}
await this.drainSavePipeline();
if (sessionAtStart !== this.projectSession) {
throw new Error('Открытие проекта отменено');
}
this.projectSession += 1;
const openSession = this.projectSession;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
@@ -256,6 +279,11 @@ export class ZipProjectStore {
throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`);
}
if (openSession !== this.projectSession) {
await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error('Открытие проекта отменено');
}
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
const parsed = JSON.parse(projectRaw) as unknown as Project;
@@ -640,6 +668,8 @@ export class ZipProjectStore {
x,
y,
isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
};
await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] }));
const latest = this.getOpenProject();
@@ -655,10 +685,59 @@ export class ZipProjectStore {
}
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => ({
...n,
isStartScene: graphNodeId !== null && n.id === graphNodeId,
})),
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
const isMain = graphNodeId !== null && n.id === graphNodeId;
if (isMain) {
return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
}
return { ...n, isStartScene: false };
}),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setSceneGraphNodeSideStoryStart(graphNodeId: GraphNodeId | null): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (graphNodeId === null) {
throw new Error('Graph node id required');
}
if (!open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
throw new Error('Graph node not found');
}
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node) throw new Error('Graph node not found');
const enabling = !node.isSideStoryStart;
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
return open.project;
}
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
if (n.id !== graphNodeId) return n;
if (enabling) {
return { ...n, isSideStoryStart: true, isStartScene: false };
}
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
}),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateSideStoryLineTitle(graphNodeId: GraphNodeId, title: string): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node?.isSideStoryStart) throw new Error('Not a side story start node');
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) =>
n.id === graphNodeId ? { ...n, sideStoryLineTitle: title } : n,
),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
@@ -668,6 +747,61 @@ export class ZipProjectStore {
async removeSceneGraphNode(nodeId: GraphNodeId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const node = open.project.sceneGraphNodes.find((n) => n.id === nodeId);
if (!node) throw new Error('Graph node not found');
if (node.isSideStoryStart) {
const outgoing = open.project.sceneGraphEdges
.filter((e) => e.sourceGraphNodeId === nodeId)
.sort((a, b) => a.id.localeCompare(b.id));
if (outgoing.length > 0) {
const newStartId = outgoing[0]?.targetGraphNodeId;
if (!newStartId) throw new Error('Invalid graph edge');
const nextNodes = open.project.sceneGraphNodes
.filter((gn) => gn.id !== nodeId)
.map((gn) =>
gn.id === newStartId
? {
...gn,
isSideStoryStart: true,
isStartScene: false,
sideStoryLineTitle: node.sideStoryLineTitle,
}
: gn,
);
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 out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
const removeIds = getSideStoryComponentNodeIds(
open.project.sceneGraphNodes,
open.project.sceneGraphEdges,
nodeId,
);
const nextNodes = open.project.sceneGraphNodes.filter((gn) => !removeIds.has(gn.id));
const nextEdges = open.project.sceneGraphEdges.filter(
(e) => !removeIds.has(e.sourceGraphNodeId) && !removeIds.has(e.targetGraphNodeId),
);
await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
const nextNodes = open.project.sceneGraphNodes.filter((gn) => gn.id !== nodeId);
const nextEdges = open.project.sceneGraphEdges.filter(
(e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId,
@@ -847,12 +981,14 @@ export class ZipProjectStore {
}
async closeOpenProject(): Promise<void> {
if (!this.openProject) return;
await this.saveNow();
await this.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;
return this.enqueueProjectSwitch(async () => {
this.projectSession += 1;
if (!this.openProject) return;
await this.saveNow();
await this.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
});
}
async renameOpenProject(name: string, fileBaseName: string): Promise<Project> {
@@ -1116,6 +1252,256 @@ export class ZipProjectStore {
}
}
/** Снимок проекта для экспорта/списка линий (распаковывает во временный кэш при необходимости). */
private async loadProjectSnapshot(
projectId: ProjectId,
): Promise<{ project: Project; cacheDir: string; ownsCache: boolean }> {
await this.drainSavePipeline();
if (this.openProject?.id === projectId) {
await this.saveNow();
return {
project: structuredClone(this.openProject.project),
cacheDir: this.openProject.cacheDir,
ownsCache: false,
};
}
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 project = normalizeProject(await readProjectJsonFromZip(zipPath));
const cacheDir = path.join(tmpdir(), `dnd_snap_${crypto.randomBytes(8).toString('hex')}`);
await unzipToDir(zipPath, cacheDir);
return { project, cacheDir, ownsCache: true };
}
async getProjectStorylines(
projectId: ProjectId,
labels: StorylineLabels,
): Promise<StorylineListItem[]> {
const snap = await this.loadProjectSnapshot(projectId);
try {
return listExportableStorylines(snap.project, labels);
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async exportStorylinesZipToPath(
projectId: ProjectId,
selections: StorylineSelection[],
destinationPath: string,
labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
): Promise<void> {
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
await this.ensureRoots();
const snap = await this.loadProjectSnapshot(projectId);
const exportCache = path.join(tmpdir(), `dnd_pexport_${crypto.randomBytes(8).toString('hex')}`);
try {
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
const partial = buildPartialExportProject(snap.project, selections, {
newProjectId: newExportBundleProjectId(),
exportTitle: entry?.name ?? snap.project.meta.name,
labels,
});
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
const assetIds = Object.keys(partial.assets) as AssetId[];
for (let i = 0; i < assetIds.length; i += 1) {
const id = assetIds[i]!;
const a = partial.assets[id];
if (!a) continue;
const srcAbs = path.join(snap.cacheDir, a.relPath);
const destAbs = path.join(exportCache, a.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 80);
onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' });
}
}
const now = new Date().toISOString();
const toWrite: Project = {
...partial,
meta: { ...partial.meta, updatedAt: now, appVersion: getAppSemanticVersion() },
};
await atomicWriteFile(path.join(exportCache, 'project.json'), JSON.stringify(toWrite, null, 2));
const tmpZip = `${path.resolve(destinationPath)}.tmp`;
if (onProgress) onProgress({ stage: 'zip', percent: 90, detail: 'Сборка архива…' });
await zipDir(exportCache, tmpZip);
await replaceFileAtomic(tmpZip, path.resolve(destinationPath));
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
} finally {
await fs.rm(exportCache, { recursive: true, force: true }).catch(() => undefined);
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async readExternalProjectForImport(sourcePath: string): Promise<Project> {
const resolved = path.resolve(sourcePath);
const st = await fs.stat(resolved).catch(() => null);
if (!st?.isFile()) throw new Error('Файл проекта не найден');
if (!isProjectZipFileName(resolved)) {
throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip');
}
return normalizeProject(await readProjectJsonFromZip(resolved));
}
async peekImportFromProjectId(
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
): Promise<{
sourceProjectId: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}> {
const snap = await this.loadProjectSnapshot(sourceProjectId);
try {
return {
sourceProjectId,
projectName: snap.project.meta.name,
storylines: listExportableStorylines(snap.project, labels).map((item) => {
if (item.selection.kind === 'main' && targetHasMainStart) {
return { ...item, disabled: true, disabledReason: 'main_exists' };
}
return item;
}),
sourceProject: structuredClone(snap.project),
};
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async peekImportFromZipPath(
sourcePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
): Promise<{
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}> {
const project = await this.readExternalProjectForImport(sourcePath);
return {
filePath: path.resolve(sourcePath),
projectName: project.meta.name,
storylines: listImportableStorylines(project, labels, targetHasMainStart),
sourceProject: project,
};
}
private assertStorylineMergeAllowed(selections: StorylineSelection[]): void {
if (!this.openProject) throw new Error('Нет открытого проекта');
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
if (
selections.some((s) => s.kind === 'main') &&
this.openProject.project.sceneGraphNodes.some((n) => n.isStartScene)
) {
throw new Error('В проекте уже есть основная сюжетная линия');
}
}
private async mergeStorylinesFromSourceCache(
source: Project,
sourceCache: string,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
if (!this.openProject) throw new Error('Нет открытого проекта');
const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
this.openProject.project,
source,
selections,
sceneResolutions,
{ graphOffsetX: offsetX },
);
const targetCache = this.openProject.cacheDir;
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
for (let i = 0; i < assetCopies.length; i += 1) {
const { fromId, toId } = assetCopies[i]!;
const srcAsset = source.assets[fromId];
const tgtMeta = merged.assets[toId];
if (!srcAsset || !tgtMeta) continue;
const srcAbs = path.join(sourceCache, srcAsset.relPath);
const destAbs = path.join(targetCache, tgtMeta.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
onProgress({ stage: 'copy', percent: pct, detail: 'Копирование материалов…' });
}
}
const normalized = normalizeProject(merged);
const reconciled = await reconcileAssetFiles(this.openProject.project, normalized, targetCache);
await this.updateProject(() => reconciled);
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
return { project: latest, report };
}
async mergeStorylinesFromProjectId(
sourceProjectId: ProjectId,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const snap = await this.loadProjectSnapshot(sourceProjectId);
try {
return await this.mergeStorylinesFromSourceCache(
snap.project,
snap.cacheDir,
selections,
sceneResolutions,
onProgress,
);
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async mergeStorylinesFromExternalZip(
sourcePath: string,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const source = await this.readExternalProjectForImport(sourcePath);
const sourceCache = path.join(tmpdir(), `dnd_mimport_${crypto.randomBytes(8).toString('hex')}`);
await unzipToDir(path.resolve(sourcePath), sourceCache);
try {
return await this.mergeStorylinesFromSourceCache(
source,
sourceCache,
selections,
sceneResolutions,
onProgress,
);
} finally {
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
}
}
private randomId(): string {
return crypto.randomBytes(16).toString('hex');
}
@@ -1166,6 +1552,8 @@ function migrateSceneGraphFromLegacy(scenes: Record<SceneId, Scene>): {
x: s.layout.x,
y: s.layout.y,
isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
}));
const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n]));
const sceneGraphEdges: SceneGraphEdge[] = [];
@@ -1189,10 +1577,18 @@ function migrateSceneGraphFromLegacy(scenes: Record<SceneId, Scene>): {
/** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */
function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] {
const withDefaults = nodes.map((n) => {
const raw = n as unknown as { isStartScene?: boolean };
const raw = n as unknown as {
isStartScene?: boolean;
isSideStoryStart?: boolean;
sideStoryLineTitle?: string;
};
const isStartScene = raw.isStartScene === true;
const isSideStoryStart = raw.isSideStoryStart === true && !isStartScene;
return {
...n,
isStartScene: raw.isStartScene === true,
isStartScene,
isSideStoryStart,
sideStoryLineTitle: isSideStoryStart ? (raw.sideStoryLineTitle ?? '').trim() : '',
};
});
const starters = withDefaults.filter((n) => n.isStartScene);