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:
+413
-17
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user