diff --git a/app/main/foundry/foundryDb.ts b/app/main/foundry/foundryDb.ts new file mode 100644 index 0000000..9d949ff --- /dev/null +++ b/app/main/foundry/foundryDb.ts @@ -0,0 +1,242 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { ClassicLevel } from 'classic-level'; + +import type { + FoundryActorDoc, + FoundryAdventureDoc, + FoundryFolderDoc, + FoundryJournalDoc, + FoundryLoadedDocuments, + FoundryPlaylistDoc, + FoundrySceneDoc, +} from '../../shared/foundry/foundryTypes'; + +function isRecord(v: unknown): v is Record { + return Boolean(v) && typeof v === 'object' && !Array.isArray(v); +} + +function asDocArray(docs: unknown[]): T[] { + return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[]; +} + +/** NeDB: по строке JSON на линию (игнор пустых / битых). */ +export async function readNedbDocuments(filePath: string): Promise[]> { + const raw = await fs.readFile(filePath, 'utf8'); + const out: Record[] = []; + for (const line of raw.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed: unknown = JSON.parse(trimmed); + if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed); + } catch { + // skip broken line + } + } + return out; +} + +function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean { + const names = new Set(entries.map((e) => e.name)); + return ( + names.has('CURRENT') || + names.has('LOG') || + [...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-')) + ); +} + +/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */ +export async function readLevelDbDocuments(dirPath: string): Promise[]> { + const db = new ClassicLevel(dirPath, { + keyEncoding: 'utf8', + valueEncoding: 'json', + createIfMissing: false, + }); + try { + const out: Record[] = []; + for await (const [key, value] of db.iterator()) { + if (typeof key !== 'string') continue; + const parts = key.split('!'); + // "", collection, id + if (parts.length < 3) continue; + const collection = parts[1] ?? ''; + if (!collection || collection.includes('.')) continue; // embedded + if (!isRecord(value)) continue; + if (typeof value._id !== 'string') continue; + out.push(value); + } + return out; + } finally { + await db.close().catch(() => undefined); + } +} + +async function pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function readCollectionDocs(baseDir: string, collection: string): Promise[]> { + const candidates = [ + path.join(baseDir, collection), + path.join(baseDir, `${collection}.db`), + path.join(baseDir, 'data', collection), + path.join(baseDir, 'data', `${collection}.db`), + ]; + + for (const candidate of candidates) { + if (!(await pathExists(candidate))) continue; + const st = await fs.stat(candidate); + if (st.isFile() && candidate.endsWith('.db')) { + return readNedbDocuments(candidate); + } + if (st.isDirectory()) { + const entries = await fs.readdir(candidate, { withFileTypes: true }); + if (isLevelDbDir(entries)) { + return readLevelDbDocuments(candidate); + } + } + } + return []; +} + +export async function readPackDocuments( + packAbsPath: string, + documentType: string, +): Promise[]> { + if (!(await pathExists(packAbsPath))) return []; + const st = await fs.stat(packAbsPath); + if (st.isFile() && packAbsPath.endsWith('.db')) { + return readNedbDocuments(packAbsPath); + } + if (st.isDirectory()) { + const entries = await fs.readdir(packAbsPath, { withFileTypes: true }); + if (isLevelDbDir(entries)) { + return readLevelDbDocuments(packAbsPath); + } + // Иногда path указывает на папку с .db внутри. + const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`); + if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb); + } + void documentType; + return []; +} + +function mergeById(lists: T[][]): T[] { + const map = new Map(); + for (const list of lists) { + for (const doc of list) { + if (!map.has(doc._id)) map.set(doc._id, doc); + } + } + return [...map.values()]; +} + +function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): { + scenes: FoundrySceneDoc[]; + actors: FoundryActorDoc[]; + playlists: FoundryPlaylistDoc[]; + journals: FoundryJournalDoc[]; + folders: FoundryFolderDoc[]; +} { + const scenes: FoundrySceneDoc[] = []; + const actors: FoundryActorDoc[] = []; + const playlists: FoundryPlaylistDoc[] = []; + const journals: FoundryJournalDoc[] = []; + const folders: FoundryFolderDoc[] = []; + for (const adv of adventures) { + if (Array.isArray(adv.scenes)) scenes.push(...asDocArray(adv.scenes)); + if (Array.isArray(adv.actors)) actors.push(...asDocArray(adv.actors)); + if (Array.isArray(adv.playlists)) playlists.push(...asDocArray(adv.playlists)); + if (Array.isArray(adv.journal)) journals.push(...asDocArray(adv.journal)); + if (Array.isArray(adv.folders)) { + for (const f of adv.folders) { + if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') { + folders.push(f as FoundryFolderDoc); + } + } + } + } + return { scenes, actors, playlists, journals, folders }; +} + +function asFolderArray(docs: unknown[]): FoundryFolderDoc[] { + return docs.filter( + (d): d is FoundryFolderDoc => + Boolean(d) && + typeof d === 'object' && + typeof (d as FoundryFolderDoc)._id === 'string' && + typeof (d as FoundryFolderDoc).name === 'string', + ); +} + +export async function loadWorldDocuments(rootDir: string): Promise { + const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] = + await Promise.all([ + readCollectionDocs(rootDir, 'scenes'), + readCollectionDocs(rootDir, 'actors'), + readCollectionDocs(rootDir, 'playlists'), + readCollectionDocs(rootDir, 'journal'), + readCollectionDocs(rootDir, 'adventures'), + readCollectionDocs(rootDir, 'folders'), + ]); + + const adventures = asDocArray(adventuresRaw); + const embedded = flattenAdventureDocs(adventures); + + return { + scenes: mergeById([asDocArray(scenesRaw), embedded.scenes]), + actors: mergeById([asDocArray(actorsRaw), embedded.actors]), + playlists: mergeById([asDocArray(playlistsRaw), embedded.playlists]), + journals: mergeById([asDocArray(journalsRaw), embedded.journals]), + adventures, + folders: mergeById([asFolderArray(foldersRaw), embedded.folders]), + }; +} + +export async function loadModuleDocuments( + rootDir: string, + packs: { path: string; type: string }[], +): Promise { + const scenes: FoundrySceneDoc[][] = []; + const actors: FoundryActorDoc[][] = []; + const playlists: FoundryPlaylistDoc[][] = []; + const journals: FoundryJournalDoc[][] = []; + const adventures: FoundryAdventureDoc[][] = []; + + for (const pack of packs) { + const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path); + // path в module.json иногда с `./` и иногда ещё со старым `.db` + const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')]; + let docs: Record[] = []; + for (const v of variants) { + docs = await readPackDocuments(v, pack.type); + if (docs.length > 0) break; + } + + const type = pack.type; + if (type === 'Scene') scenes.push(asDocArray(docs)); + else if (type === 'Actor') actors.push(asDocArray(docs)); + else if (type === 'Playlist') playlists.push(asDocArray(docs)); + else if (type === 'JournalEntry') journals.push(asDocArray(docs)); + else if (type === 'Adventure') adventures.push(asDocArray(docs)); + } + + const advMerged = mergeById(adventures); + const embedded = flattenAdventureDocs(advMerged); + + return { + scenes: mergeById([...scenes, embedded.scenes]), + actors: mergeById([...actors, embedded.actors]), + playlists: mergeById([...playlists, embedded.playlists]), + journals: mergeById([...journals, embedded.journals]), + adventures: advMerged, + folders: mergeById([embedded.folders]), + }; +} diff --git a/app/main/foundry/foundryDetect.ts b/app/main/foundry/foundryDetect.ts new file mode 100644 index 0000000..2f8d25f --- /dev/null +++ b/app/main/foundry/foundryDetect.ts @@ -0,0 +1,209 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { decodeFoundryAssetPath } from '../../shared/foundry/foundryPaths'; +import type { FoundryPackageManifest, FoundryPackRef } from '../../shared/foundry/foundryTypes'; +import { isSupportedFoundryPackage } from '../../shared/foundry/foundryVersion'; + +function isRecord(v: unknown): v is Record { + return Boolean(v) && typeof v === 'object' && !Array.isArray(v); +} + +async function readJsonFile(filePath: string): Promise { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw) as unknown; +} + +function parsePacks(raw: unknown): FoundryPackRef[] { + if (!Array.isArray(raw)) return []; + const out: FoundryPackRef[] = []; + for (const item of raw) { + if (!isRecord(item)) continue; + const name = typeof item.name === 'string' ? item.name : ''; + const label = typeof item.label === 'string' ? item.label : name; + const packPath = typeof item.path === 'string' ? item.path.replace(/^\.\//u, '') : ''; + const type = + typeof item.type === 'string' ? item.type : typeof item.entity === 'string' ? item.entity : ''; + if (!packPath || !type) continue; + out.push({ name: name || path.basename(packPath), label: label || name, path: packPath, type }); + } + return out; +} + +function titleFromManifest(data: Record, fallbackId: string): string { + if (typeof data.title === 'string' && data.title.trim()) return data.title.trim(); + if (typeof data.name === 'string' && data.name.trim()) return data.name.trim(); + return fallbackId; +} + +function idFromManifest(data: Record, dirName: string): string { + if (typeof data.id === 'string' && data.id.trim()) return data.id.trim(); + if (typeof data.name === 'string' && data.name.trim()) return data.name.trim(); + return dirName; +} + +async function tryParseManifest(dir: string): Promise { + const worldPath = path.join(dir, 'world.json'); + const modulePath = path.join(dir, 'module.json'); + + try { + await fs.access(worldPath); + const data = await readJsonFile(worldPath); + if (!isRecord(data)) return null; + const id = idFromManifest(data, path.basename(dir)); + const manifest: FoundryPackageManifest = { + kind: 'world', + id, + title: titleFromManifest(data, id), + rootDir: dir, + packs: [], + }; + const compatibility = parseCompatibility(data.compatibility); + if (compatibility) manifest.compatibility = compatibility; + if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion; + return manifest; + } catch { + // not a world + } + + try { + await fs.access(modulePath); + const data = await readJsonFile(modulePath); + if (!isRecord(data)) return null; + const id = idFromManifest(data, path.basename(dir)); + const manifest: FoundryPackageManifest = { + kind: 'module', + id, + title: titleFromManifest(data, id), + rootDir: dir, + packs: parsePacks(data.packs), + }; + const compatibility = parseCompatibility(data.compatibility); + if (compatibility) manifest.compatibility = compatibility; + if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion; + return manifest; + } catch { + return null; + } +} + +function parseCompatibility(raw: unknown): FoundryPackageManifest['compatibility'] | null { + if (!isRecord(raw)) return null; + const out: NonNullable = {}; + if (typeof raw.minimum === 'string') out.minimum = raw.minimum; + if (typeof raw.verified === 'string') out.verified = raw.verified; + if (typeof raw.maximum === 'string') out.maximum = raw.maximum; + return out; +} + +/** Ищет world.json / module.json в папке и на 1–2 уровня глубже. */ +export async function detectFoundryPackage(rootPath: string): Promise { + const abs = path.resolve(rootPath); + const direct = await tryParseManifest(abs); + if (direct) { + const ver = isSupportedFoundryPackage(direct); + if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version'); + return direct; + } + + // Архив мог содержать один корневой каталог. + const entries = await fs.readdir(abs, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(abs, e.name)); + + for (const dir of dirs) { + const found = await tryParseManifest(dir); + if (found) { + const ver = isSupportedFoundryPackage(found); + if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version'); + return found; + } + } + + // Data/worlds/ или Data/modules/ + for (const mid of ['worlds', 'modules']) { + const midDir = path.join(abs, mid); + try { + const st = await fs.stat(midDir); + if (!st.isDirectory()) continue; + const children = await fs.readdir(midDir, { withFileTypes: true }); + for (const child of children.filter((c) => c.isDirectory())) { + const found = await tryParseManifest(path.join(midDir, child.name)); + if (found) { + const ver = isSupportedFoundryPackage(found); + if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version'); + return found; + } + } + } catch { + // ignore + } + } + + // Data как корень выбранной папки + const dataDir = path.join(abs, 'Data'); + try { + const st = await fs.stat(dataDir); + if (st.isDirectory()) { + return await detectFoundryPackage(dataDir); + } + } catch { + // ignore + } + + throw new Error( + 'Не похоже на пакет Foundry VTT: не найдены world.json или module.json. Выберите папку мира/модуля или архив с ними.', + ); +} + +/** + * Резолвит путь ассета Foundry относительно корня пакета / Data. + * foundryPath — как в документах: `worlds/id/...`, `modules/id/...` или относительный. + * Поддерживает URL-encoding (`The%20Withered%20Grove%20(day).webp`). + */ +export async function resolveFoundryAssetPath( + manifest: FoundryPackageManifest, + foundryPath: string, +): Promise { + const cleaned = decodeFoundryAssetPath(foundryPath); + if (!cleaned) return null; + if (/^https?:\/\//iu.test(cleaned)) return null; + + const packagePrefix = manifest.kind === 'world' ? `worlds/${manifest.id}/` : `modules/${manifest.id}/`; + + const rawNormalized = foundryPath.trim().replace(/\\/gu, '/'); + const pathVariants = cleaned === rawNormalized ? [cleaned] : [cleaned, rawNormalized]; + + const candidates: string[] = []; + for (const variant of pathVariants) { + const v = variant.replace(/^\/+/u, ''); + if (!v) continue; + if (v.toLowerCase().startsWith(packagePrefix.toLowerCase())) { + candidates.push(path.join(manifest.rootDir, v.slice(packagePrefix.length))); + } + candidates.push(path.join(manifest.rootDir, v)); + + const dataRootGuesses = [ + path.resolve(manifest.rootDir, '..', '..'), + path.resolve(manifest.rootDir, '..'), + ]; + for (const dataRoot of dataRootGuesses) { + candidates.push(path.join(dataRoot, v)); + } + + const base = path.basename(v); + if (base && base !== v) { + candidates.push(path.join(manifest.rootDir, base)); + candidates.push(path.join(manifest.rootDir, 'assets', base)); + } + } + + for (const c of candidates) { + try { + const st = await fs.stat(c); + if (st.isFile()) return c; + } catch { + // next + } + } + return null; +} diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts new file mode 100644 index 0000000..c87cb36 --- /dev/null +++ b/app/main/foundry/foundryImport.ts @@ -0,0 +1,654 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + actorPortraitSrc, + extractActorDescriptionHtml, + filterScenesForImport, + journalDescriptionHtml, + planFoundrySceneGraph, + sceneBackgroundSrc, +} from '../../shared/foundry/foundryGraph'; +import type { + FoundryFolderDoc, + FoundryLoadedDocuments, + FoundryPackageManifest, + FoundryPlaylistDoc, + FoundrySceneDoc, +} from '../../shared/foundry/foundryTypes'; +import { noneBinding } from '../../shared/npcs/npcBinding'; +import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups'; +import type { + MediaAsset, + Project, + ProjectNpc, + ProjectNpcGroup, + Scene, + SceneGraphEdge, + SceneGraphNode, +} from '../../shared/types'; +import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; +import type { AssetId, GraphNodeId, NpcGroupId, SceneId } from '../../shared/types/ids'; +import { + asAssetId, + asGraphNodeId, + asNpcGroupId, + asNpcId, + asProjectId, + asSceneId, +} from '../../shared/types/ids'; +import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs'; +import { generateScenePreviewThumbnailBytes } from '../project/scenePreviewThumbnail'; +import { unzipToDir } from '../project/yauzlProjectZip'; +import { getAppSemanticVersion } from '../versionInfo'; + +import { loadModuleDocuments, loadWorldDocuments } from './foundryDb'; +import { detectFoundryPackage, resolveFoundryAssetPath } from './foundryDetect'; + +export type FoundryImportProgress = { + stage: 'copy' | 'unzip' | 'zip' | 'done'; + percent: number; + detail?: string; +}; + +/** Минимальный 1×1 PNG (серый), если у актёра нет портрета на диске. */ +const PLACEHOLDER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +type MediaKind = { type: 'image' | 'video' | 'audio'; 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' }; + case '.flac': + return { type: 'audio', mime: 'audio/flac' }; + default: + return null; + } +} + +function sanitizeFileName(name: string): string { + const cleaned = name + .split('') + .map((ch) => { + const code = ch.charCodeAt(0); + if (code < 32 || '<>:"/\\|?*'.includes(ch)) return '_'; + return ch; + }) + .join('') + .trim(); + return cleaned.length > 0 ? cleaned.slice(0, 180) : 'file'; +} + +function randomId(): string { + return crypto.randomBytes(16).toString('hex'); +} + +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 isZipFile(filePath: string): Promise { + try { + const st = await fs.stat(filePath); + if (!st.isFile()) return false; + const ext = path.extname(filePath).toLowerCase(); + return ext === '.zip' || ext === '.fvtt'; + } catch { + return false; + } +} + +async function prepareSourceDir( + sourcePath: string, + onProgress?: (p: FoundryImportProgress) => void, +): Promise<{ workDir: string; cleanup: () => Promise }> { + const abs = path.resolve(sourcePath); + if (await isZipFile(abs)) { + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ttrpg-foundry-')); + onProgress?.({ stage: 'unzip', percent: 5, detail: 'Распаковка архива Foundry…' }); + await unzipToDir(abs, workDir, (done, total) => { + const pct = total > 0 ? 5 + Math.round((done / total) * 25) : 15; + onProgress?.({ stage: 'unzip', percent: Math.min(30, pct), detail: 'Распаковка архива Foundry…' }); + }); + return { + workDir, + cleanup: async () => { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined); + }, + }; + } + const st = await fs.stat(abs); + if (!st.isDirectory()) { + throw new Error('Выберите папку мира/модуля Foundry или архив (.zip / .fvtt).'); + } + return { + workDir: abs, + cleanup: () => Promise.resolve(), + }; +} + +type AssetWriteCtx = { + cacheDir: string; + assets: Record; + /** foundry path or abs path → assetId */ + bySourceKey: Map; +}; + +async function importFileAsAsset( + ctx: AssetWriteCtx, + absPath: string, + opts?: { optimizeImage?: boolean; forceKind?: MediaKind }, +): Promise { + const key = path.resolve(absPath).toLowerCase(); + const existing = ctx.bySourceKey.get(key); + if (existing) return existing; + + let kind = opts?.forceKind ?? classifyMediaPath(absPath); + if (!kind) return null; + + let buf = await fs.readFile(absPath); + if (kind.type === 'image' && opts?.optimizeImage !== false) { + try { + const opt = await optimizeImageBufferVisuallyLossless(buf); + if (!opt.passthrough && opt.buffer.length > 0) { + buf = Buffer.from(opt.buffer); + kind = { type: 'image', mime: opt.mime }; + } + } catch { + // keep original + } + } + + const id = asAssetId(randomId()); + const orig = path.basename(absPath); + const safeOrig = sanitizeFileName(orig); + const relPath = `assets/${id}_${safeOrig}`; + const absOut = path.join(ctx.cacheDir, relPath); + await fs.mkdir(path.dirname(absOut), { recursive: true }); + await fs.writeFile(absOut, buf); + const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); + ctx.assets[id] = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); + ctx.bySourceKey.set(key, id); + return id; +} + +async function importPlaceholderAvatar(ctx: AssetWriteCtx, name: string): Promise { + const id = asAssetId(randomId()); + const orig = `${sanitizeFileName(name)}_avatar.png`; + const relPath = `assets/${id}_${orig}`; + const absOut = path.join(ctx.cacheDir, relPath); + await fs.mkdir(path.dirname(absOut), { recursive: true }); + await fs.writeFile(absOut, PLACEHOLDER_PNG); + const sha256 = crypto.createHash('sha256').update(PLACEHOLDER_PNG).digest('hex'); + ctx.assets[id] = buildMediaAsset( + id, + { type: 'image', mime: 'image/png' }, + orig, + relPath, + sha256, + PLACEHOLDER_PNG.length, + ); + return id; +} + +async function resolveAndImport( + ctx: AssetWriteCtx, + manifest: FoundryPackageManifest, + foundryPath: string | null | undefined, + opts?: { optimizeImage?: boolean }, +): Promise { + if (!foundryPath?.trim()) return null; + const abs = await resolveFoundryAssetPath(manifest, foundryPath); + if (!abs) return null; + return importFileAsAsset(ctx, abs, opts); +} + +function uniqueNpcName(base: string, used: Set): string { + const root = base.trim() || 'NPC'; + if (!used.has(root.toLowerCase())) { + used.add(root.toLowerCase()); + return root; + } + let i = 2; + while (used.has(`${root} (${String(i)})`.toLowerCase())) i += 1; + const name = `${root} (${String(i)})`; + used.add(name.toLowerCase()); + return name; +} + +function collectSceneAudioPaths( + scene: FoundrySceneDoc, + playlistsById: Map, +): string[] { + const paths: string[] = []; + const playlistId = typeof scene.playlist === 'string' ? scene.playlist : null; + if (!playlistId) return paths; + const playlist = playlistsById.get(playlistId); + if (!playlist?.sounds?.length) return paths; + + const soundId = typeof scene.playlistSound === 'string' ? scene.playlistSound : null; + const sounds = soundId + ? playlist.sounds.filter((s) => s._id === soundId) + : [...playlist.sounds].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)); + + for (const s of sounds) { + if (typeof s.path === 'string' && s.path.trim()) paths.push(s.path.trim()); + } + return paths; +} + +export type FoundryBuiltProject = { + project: Project; + /** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */ + previewSources: { sceneId: SceneId; absPath: string }[]; +}; + +export async function buildProjectFromFoundryDocuments( + manifest: FoundryPackageManifest, + docs: FoundryLoadedDocuments, + cacheDir: string, + onProgress?: (p: FoundryImportProgress) => void, + options?: { projectId?: ReturnType }, +): Promise { + const projectId = options?.projectId ?? asProjectId(randomId()); + const now = new Date().toISOString(); + const appVer = getAppSemanticVersion(); + const name = manifest.title.trim() || manifest.id; + const fileBaseName = `${sanitizeFileName(name)}_${projectId}`; + + const ctx: AssetWriteCtx = { cacheDir, assets: {}, bySourceKey: new Map() }; + const journalsById = new Map(docs.journals.map((j) => [j._id, j])); + const playlistsById = new Map(docs.playlists.map((p) => [p._id, p])); + + const importScenes = filterScenesForImport(docs.scenes); + const graphPlan = planFoundrySceneGraph(importScenes, docs.adventures, docs.journals); + const sceneOrderIds = + graphPlan.orderedSceneIds.length > 0 ? graphPlan.orderedSceneIds : importScenes.map((s) => s._id); + + const scenesByFoundryId = new Map(importScenes.map((s) => [s._id, s])); + const foundryToSceneId = new Map(); + const scenes: Record = {}; + const sceneListOrder: SceneId[] = []; + const previewSources: { sceneId: SceneId; absPath: string }[] = []; + + const totalSteps = Math.max(1, sceneOrderIds.length + docs.actors.length); + let step = 0; + + for (const foundrySceneId of sceneOrderIds) { + const doc = scenesByFoundryId.get(foundrySceneId); + if (!doc) continue; + step += 1; + onProgress?.({ + stage: 'copy', + percent: 30 + Math.round((step / totalSteps) * 50), + detail: `Сцена: ${doc.name}`, + }); + + const sceneId = asSceneId(`s_${randomId()}`); + foundryToSceneId.set(foundrySceneId, sceneId); + + let previewAssetId: AssetId | null = null; + let previewAssetType: 'image' | 'video' | null = null; + let previewThumbAssetId: AssetId | null = null; + + const bg = sceneBackgroundSrc(doc); + if (bg) { + const abs = await resolveFoundryAssetPath(manifest, bg); + if (abs) { + const kind = classifyMediaPath(abs); + if (kind?.type === 'image' || kind?.type === 'video') { + previewAssetId = await importFileAsAsset(ctx, abs, { optimizeImage: kind.type === 'image' }); + previewAssetType = kind.type; + previewSources.push({ sceneId, absPath: abs }); + if (previewAssetId && kind.type === 'image') { + try { + const thumbBytes = await generateScenePreviewThumbnailBytes(abs, 'image'); + if (thumbBytes && thumbBytes.length > 0) { + const thumbId = asAssetId(randomId()); + const thumbRel = `assets/${thumbId}_preview_thumb.webp`; + await fs.writeFile(path.join(cacheDir, thumbRel), thumbBytes); + ctx.assets[thumbId] = buildMediaAsset( + thumbId, + { type: 'image', mime: 'image/webp' }, + `${sanitizeFileName(doc.name)}_preview_thumb.webp`, + thumbRel, + crypto.createHash('sha256').update(thumbBytes).digest('hex'), + thumbBytes.length, + ); + previewThumbAssetId = thumbId; + } + } catch { + // optional + } + } + } + } + } + + const audioRefs: Scene['media']['audios'] = []; + for (const audioPath of collectSceneAudioPaths(doc, playlistsById)) { + const assetId = await resolveAndImport(ctx, manifest, audioPath); + if (!assetId) continue; + if (ctx.assets[assetId]?.type !== 'audio') continue; + audioRefs.push({ assetId, autoplay: true, loop: true }); + } + + scenes[sceneId] = { + id: sceneId, + title: doc.name.trim() || 'Scene', + description: journalDescriptionHtml(doc, journalsById), + previewAssetId, + previewAssetType, + previewThumbAssetId, + previewVideoAutostart: previewAssetType === 'video', + previewRotationDeg: 0, + darkenScene: false, + media: { videos: [], audios: audioRefs }, + settings: { + autoplayVideo: previewAssetType === 'video', + autoplayAudio: true, + loopVideo: true, + loopAudio: true, + }, + connections: [], + layout: { x: 0, y: 0 }, + }; + sceneListOrder.push(sceneId); + } + + const usedPlaylistIds = new Set( + importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'), + ); + const campaignAudios: Project['campaignAudios'] = []; + for (const pl of docs.playlists) { + if (usedPlaylistIds.has(pl._id)) continue; + const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)); + for (const s of sounds) { + if (typeof s.path !== 'string' || !s.path.trim()) continue; + const assetId = await resolveAndImport(ctx, manifest, s.path); + if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue; + campaignAudios.push({ assetId, autoplay: false, loop: true }); + } + } + + const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders); + + const npcs: ProjectNpc[] = []; + const usedNpcNames = new Set(); + let npcIndex = 0; + for (const actor of docs.actors) { + // Группы актёров Foundry (party/group) — не персонажи. + if (actor.type === 'group') continue; + step += 1; + onProgress?.({ + stage: 'copy', + percent: 30 + Math.round((step / totalSteps) * 50), + detail: `НПС: ${actor.name}`, + }); + const portrait = actorPortraitSrc(actor); + let avatarAssetId = portrait ? await resolveAndImport(ctx, manifest, portrait) : null; + if (!avatarAssetId || ctx.assets[avatarAssetId]?.type !== 'image') { + avatarAssetId = await importPlaceholderAvatar(ctx, actor.name); + } + const npcName = uniqueNpcName(actor.name, usedNpcNames); + const folderId = typeof actor.folder === 'string' ? actor.folder : null; + const groupId = folderId ? (foundryFolderToGroupId.get(folderId) ?? null) : null; + npcs.push({ + id: asNpcId(`npc_${randomId()}`), + name: npcName, + avatarAssetId, + description: extractActorDescriptionHtml(actor), + x: 80 + (npcIndex % 4) * 220, + y: 80 + Math.floor(npcIndex / 4) * 200, + groupId, + binding: noneBinding(), + }); + npcIndex += 1; + } + + const sceneGraphNodes: SceneGraphNode[] = []; + const sceneGraphEdges: SceneGraphEdge[] = []; + const foundryToGraphNode = new Map(); + + const startFoundryId = graphPlan.startSceneId; + const startSceneId = + (startFoundryId ? foundryToSceneId.get(startFoundryId) : null) ?? sceneListOrder[0] ?? null; + + sceneListOrder.forEach((sceneId, index) => { + const foundryId = [...foundryToSceneId.entries()].find(([, sid]) => sid === sceneId)?.[0]; + const col = index % 4; + const row = Math.floor(index / 4); + const gnId = asGraphNodeId(`gn_${randomId()}`); + sceneGraphNodes.push({ + id: gnId, + sceneId, + x: 80 + col * 280, + y: 80 + row * 200, + isStartScene: startSceneId !== null && sceneId === startSceneId, + isSideStoryStart: false, + sideStoryLineTitle: '', + }); + if (foundryId) foundryToGraphNode.set(foundryId, gnId); + }); + + for (const edge of graphPlan.edges) { + const source = foundryToGraphNode.get(edge.sourceId); + const target = foundryToGraphNode.get(edge.targetId); + if (!source || !target || source === target) continue; + sceneGraphEdges.push({ + id: `e_${randomId()}`, + sourceGraphNodeId: source, + targetGraphNodeId: target, + }); + } + + // connections из рёбер графа + const outgoing = new Map>(); + const gnMap = new Map(sceneGraphNodes.map((n) => [n.id, n])); + for (const e of sceneGraphEdges) { + const a = gnMap.get(e.sourceGraphNodeId); + const b = gnMap.get(e.targetGraphNodeId); + if (!a || !b || 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); + } + for (const [sid, set] of outgoing) { + const sc = scenes[sid]; + if (sc) sc.connections = [...set]; + } + + const startGraphNodeId = sceneGraphNodes.find((n) => n.isStartScene)?.id ?? null; + + const project: Project = { + id: projectId, + meta: { + name, + fileBaseName, + createdAt: now, + updatedAt: now, + createdWithAppVersion: appVer, + appVersion: appVer, + schemaVersion: PROJECT_SCHEMA_VERSION, + }, + scenes, + sceneListOrder, + assets: ctx.assets, + campaignAudios, + materials: [], + npcs, + npcGroups, + npcRelations: [], + currentSceneId: startSceneId, + currentGraphNodeId: startGraphNodeId, + sceneGraphNodes, + sceneGraphEdges, + }; + + return { project, previewSources }; +} + +/** Actor folders Foundry → дерево групп НПС. */ +function buildNpcGroupsFromFoundryFolders(folders: FoundryFolderDoc[]): { + npcGroups: ProjectNpcGroup[]; + foundryFolderToGroupId: Map; +} { + const actorFolders = folders + .filter((f) => !f.type || f.type === 'Actor') + .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.name.localeCompare(b.name)); + + const foundryFolderToGroupId = new Map(); + for (const f of actorFolders) { + foundryFolderToGroupId.set(f._id, asNpcGroupId(`ng_${randomId()}`)); + } + + const nameKeysByParent = new Map>(); + const npcGroups: ProjectNpcGroup[] = []; + + // Parents first + const remaining = [...actorFolders]; + const placed = new Set(); + while (remaining.length > 0) { + let progress = false; + for (let i = 0; i < remaining.length; i += 1) { + const f = remaining[i]!; + const parentFoundry = typeof f.folder === 'string' && f.folder.trim() ? f.folder.trim() : null; + if (parentFoundry && !foundryFolderToGroupId.has(parentFoundry)) { + // orphan parent ref → root + } else if (parentFoundry && !placed.has(parentFoundry) && foundryFolderToGroupId.has(parentFoundry)) { + continue; + } + const parentId = parentFoundry ? (foundryFolderToGroupId.get(parentFoundry) ?? null) : null; + const parentKey = parentId; + let keys = nameKeysByParent.get(parentKey); + if (!keys) { + keys = new Set(); + nameKeysByParent.set(parentKey, keys); + } + let name = f.name.trim() || 'Group'; + const base = name.toLowerCase(); + if (keys.has(base)) { + let n = 2; + while (keys.has(`${base} (${String(n)})`)) n += 1; + name = `${name} (${String(n)})`; + } + keys.add(name.toLowerCase()); + const id = foundryFolderToGroupId.get(f._id)!; + npcGroups.push({ + id, + name, + color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR), + parentId, + }); + placed.add(f._id); + remaining.splice(i, 1); + progress = true; + break; + } + if (!progress) { + // cycle / leftover → force as roots + for (const f of remaining) { + const id = foundryFolderToGroupId.get(f._id)!; + let name = f.name.trim() || 'Group'; + const keys = nameKeysByParent.get(null) ?? new Set(); + nameKeysByParent.set(null, keys); + if (keys.has(name.toLowerCase())) { + let n = 2; + while (keys.has(`${name.toLowerCase()} (${String(n)})`)) n += 1; + name = `${name} (${String(n)})`; + } + keys.add(name.toLowerCase()); + npcGroups.push({ + id, + name, + color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR), + parentId: null, + }); + } + break; + } + } + + return { npcGroups, foundryFolderToGroupId }; +} + +export async function loadFoundryDocumentsForImport( + sourcePath: string, + onProgress?: (p: FoundryImportProgress) => void, +): Promise<{ + manifest: FoundryPackageManifest; + docs: FoundryLoadedDocuments; + cleanup: () => Promise; +}> { + const prepared = await prepareSourceDir(sourcePath, onProgress); + try { + onProgress?.({ stage: 'copy', percent: 32, detail: 'Определение пакета Foundry…' }); + const manifest = await detectFoundryPackage(prepared.workDir); + onProgress?.({ + stage: 'copy', + percent: 36, + detail: manifest.kind === 'world' ? 'Чтение мира…' : 'Чтение модуля…', + }); + const docs = + manifest.kind === 'world' + ? await loadWorldDocuments(manifest.rootDir) + : await loadModuleDocuments(manifest.rootDir, manifest.packs); + + if (docs.scenes.length === 0 && docs.actors.length === 0) { + throw new Error( + 'В пакете Foundry не найдено сцен и актёров. Проверьте, что выбран мир/модуль с данными (не пустой шаблон).', + ); + } + + return { manifest, docs, cleanup: prepared.cleanup }; + } catch (e) { + await prepared.cleanup(); + throw e; + } +} diff --git a/app/main/index.ts b/app/main/index.ts index 7680595..394e1a8 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -625,42 +625,52 @@ async function main() { const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`; return { canceled: false as const, filePath, previewDataUrl }; }); - registerHandler(ipcChannels.project.upsertNpc, async ({ npcId, name, description, filePath: pathFromDrop }) => { - let filePath = pathFromDrop; - if (!filePath && !npcId) { - const { canceled, filePaths } = await dialog.showOpenDialog({ - properties: ['openFile'], - filters: [ - { - name: openDialogFilterLabel('images', app.getLocale()), - extensions: ['png', 'jpg', 'jpeg', 'webp'], - }, - ], - }); - if (canceled || filePaths.length === 0) { - throw new Error('NPC avatar is required'); + registerHandler( + ipcChannels.project.upsertNpc, + async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => { + let filePath = pathFromDrop; + if (!filePath && !npcId) { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [ + { + name: openDialogFilterLabel('images', app.getLocale()), + extensions: ['png', 'jpg', 'jpeg', 'webp'], + }, + ], + }); + if (canceled || filePaths.length === 0) { + throw new Error('NPC avatar is required'); + } + filePath = filePaths[0]; } - filePath = filePaths[0]; - } - const project = await projectStore.upsertNpc({ - ...(npcId ? { npcId } : {}), - name, - ...(typeof description === 'string' ? { description } : {}), - ...(filePath ? { filePath } : {}), - }); - syncNpcsOverlayWithProject(project); - emitNpcsOverlayState(); - emitSessionState(); - return { project }; - }); - registerHandler(ipcChannels.project.updateNpcFields, async ({ npcId, name, description }) => { - const project = await projectStore.updateNpcFields(npcId, { - ...(typeof name === 'string' ? { name } : {}), - ...(typeof description === 'string' ? { description } : {}), - }); - emitSessionState(); - return { project }; - }); + const project = await projectStore.upsertNpc({ + ...(npcId ? { npcId } : {}), + name, + ...(typeof description === 'string' ? { description } : {}), + ...(filePath ? { filePath } : {}), + ...(groupId !== undefined ? { groupId } : {}), + ...(binding !== undefined ? { binding } : {}), + }); + syncNpcsOverlayWithProject(project); + emitNpcsOverlayState(); + emitSessionState(); + return { project }; + }, + ); + registerHandler( + ipcChannels.project.updateNpcFields, + async ({ npcId, name, description, groupId, binding }) => { + const project = await projectStore.updateNpcFields(npcId, { + ...(typeof name === 'string' ? { name } : {}), + ...(typeof description === 'string' ? { description } : {}), + ...(groupId !== undefined ? { groupId } : {}), + ...(binding !== undefined ? { binding } : {}), + }); + emitSessionState(); + return { project }; + }, + ); registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => { const project = await projectStore.updateNpcPosition(npcId, x, y); emitSessionState(); @@ -715,6 +725,31 @@ async function main() { emitSessionState(); return { project }; }); + registerHandler( + ipcChannels.project.upsertNpcGroup, + async ({ groupId, name, color, parentId }) => { + const project = await projectStore.upsertNpcGroup({ + ...(groupId ? { groupId } : {}), + name, + ...(typeof color === 'string' ? { color } : {}), + ...(parentId !== undefined ? { parentId } : {}), + }); + emitSessionState(); + return { project }; + }, + ); + registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => { + const project = await projectStore.deleteNpcGroup(groupId); + syncNpcsOverlayWithProject(project); + emitNpcsOverlayState(); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.setNpcGroupsOrder, async ({ groupIds }) => { + const project = await projectStore.setNpcGroupsOrder(groupIds); + emitSessionState(); + return { project }; + }); registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => { let filePath = pathFromDrop; if (!filePath) { @@ -880,28 +915,32 @@ async function main() { 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.mergeImportZip, + async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => { + 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), + }); + }, + npcResolutions, + ); + emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); + emitSessionState(); + return { project, report }; + }, + ); registerHandler( ipcChannels.project.mergeImportFromProject, - async ({ sourceProjectId, storylineSelections, sceneResolutions }) => { + async ({ sourceProjectId, storylineSelections, sceneResolutions, npcResolutions }) => { emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' }); const { project, report } = await projectStore.mergeStorylinesFromProjectId( sourceProjectId, @@ -915,6 +954,7 @@ async function main() { ...(p.detail ? { detail: p.detail } : null), }); }, + npcResolutions, ); emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitSessionState(); @@ -935,6 +975,45 @@ async function main() { emitSessionState(); return { project }; }); + registerHandler(ipcChannels.project.pickFoundrySource, async ({ mode }) => { + if (mode === 'folder') { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Папка мира или модуля Foundry VTT', + }); + if (canceled || !filePaths[0]) return { canceled: true as const }; + return { canceled: false as const, sourcePath: filePaths[0] }; + } + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile'], + title: 'Архив мира или модуля Foundry VTT', + filters: [ + { name: 'Foundry package', extensions: ['zip', 'fvtt'] }, + { name: 'All files', extensions: ['*'] }, + ], + }); + if (canceled || !filePaths[0]) return { canceled: true as const }; + return { canceled: false as const, sourcePath: filePaths[0] }; + }); + registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => { + emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' }); + try { + const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => { + emitZipProgress({ + kind: 'import', + stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy', + percent: p.percent, + ...(p.detail ? { detail: p.detail } : null), + }); + }); + emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); + emitSessionState(); + return { project }; + } catch (e) { + emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Ошибка' }); + throw e; + } + }); registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => { const list = await projectStore.listProjects(); const entry = list.find((p) => p.id === projectId); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 8c11f25..13d276b 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -20,6 +20,7 @@ import { listImportableStorylines, mergeStorylinesIntoProject, newExportBundleProjectId, + type NpcImportResolution, type SceneImportResolution, type StorylineImportMergeReport, type StorylineLabels, @@ -35,9 +36,11 @@ import { import type { MediaAsset, MediaAssetType, + NpcBinding, Project, ProjectId, ProjectNpc, + ProjectNpcGroup, ProjectNpcRelation, Scene, SceneGraphEdge, @@ -45,15 +48,34 @@ import type { SceneId, } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; -import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId } from '../../shared/types/ids'; +import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids'; import { asAssetId, asGraphNodeId, asMaterialId, + asNpcGroupId, asNpcId, asNpcRelationId, asProjectId, } from '../../shared/types/ids'; +import { + clearNpcBindingsForDeletedScene, + clearNpcBindingsForRemovedStoryline, + noneBinding, + normalizeNpcBinding, +} from '../../shared/npcs/npcBinding'; +import { + DEFAULT_NPC_GROUP_COLOR, + normalizeHexColor, + normalizeNpcGroups, + resolveNpcGroupId, + wouldCreateGroupCycle, +} from '../../shared/npcs/npcGroups'; +import { + buildProjectFromFoundryDocuments, + loadFoundryDocumentsForImport, + type FoundryImportProgress, +} from '../foundry/foundryImport'; import { getAppSemanticVersion } from '../versionInfo'; import { reconcileAssetFiles } from './assetPrune'; @@ -236,6 +258,7 @@ export class ZipProjectStore { campaignAudios: [], materials: [], npcs: [], + npcGroups: [], npcRelations: [], currentSceneId: null, currentGraphNodeId: null, @@ -660,9 +683,25 @@ export class ZipProjectStore { currentSceneId = ids[0] ?? null; } + const removedSideStarts = p.sceneGraphNodes.filter( + (n) => n.sceneId === sceneId && n.isSideStoryStart, + ); + let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId); + for (const side of removedSideStarts) { + npcs = clearNpcBindingsForRemovedStoryline(npcs, { + kind: 'side', + startGraphNodeId: side.id, + }); + } + const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene); + if (hadMainOnScene) { + npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' }); + } + return { ...withGraph, scenes: nextScenes, + npcs, sceneListOrder: removeFromSceneListOrder( reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder), sceneId, @@ -724,16 +763,34 @@ export class ZipProjectStore { 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) => { - const isMain = graphNodeId !== null && n.id === graphNodeId; - if (isMain) { - return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' }; - } - return { ...n, isStartScene: false }; - }), - })); + const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene); + const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId); + await this.updateProject((p) => { + let npcs = p.npcs ?? []; + if (clearingMain && prevMain) { + npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' }); + } + const demotedSides = p.sceneGraphNodes.filter( + (n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId, + ); + for (const side of demotedSides) { + npcs = clearNpcBindingsForRemovedStoryline(npcs, { + kind: 'side', + startGraphNodeId: side.id, + }); + } + return { + ...p, + npcs, + 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; @@ -754,16 +811,29 @@ export class ZipProjectStore { 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: '' }; - }), - })); + await this.updateProject((p) => { + let npcs = p.npcs ?? []; + if (!enabling) { + npcs = clearNpcBindingsForRemovedStoryline(npcs, { + kind: 'side', + startGraphNodeId: graphNodeId, + }); + } + if (enabling && node.isStartScene) { + npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' }); + } + return { + ...p, + npcs, + 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; @@ -817,7 +887,23 @@ export class ZipProjectStore { 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 npcs = (p.npcs ?? []).map((n) => { + if ( + n.binding?.kind === 'storyline' && + n.binding.storyline.kind === 'side' && + n.binding.storyline.startGraphNodeId === nodeId + ) { + return { + ...n, + binding: { + kind: 'storyline' as const, + storyline: { kind: 'side' as const, startGraphNodeId: newStartId }, + }, + }; + } + return n; + }); + return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); @@ -1154,6 +1240,8 @@ export class ZipProjectStore { name: string; description?: string; filePath?: string; + groupId?: NpcGroupId | null; + binding?: NpcBinding; }): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); @@ -1199,6 +1287,13 @@ export class ZipProjectStore { const assets = { ...p.assets }; if (stagedAsset) assets[stagedAsset.id] = stagedAsset; + const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id)); + const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => { + if (raw === undefined) return prev; + if (raw === null) return null; + return groupIds.has(raw) ? raw : null; + }; + if (editingId) { const idx = npcs.findIndex((n) => n.id === editingId); if (idx < 0) throw new Error('NPC not found'); @@ -1209,6 +1304,8 @@ export class ZipProjectStore { avatarAssetId: nextAssetId ?? prev.avatarAssetId, description: typeof input.description === 'string' ? input.description : prev.description, + groupId: resolveGroup(input.groupId, prev.groupId), + binding: input.binding !== undefined ? input.binding : prev.binding, }; } else { if (!nextAssetId) throw new Error('NPC avatar is required'); @@ -1220,6 +1317,8 @@ export class ZipProjectStore { description: typeof input.description === 'string' ? input.description : '', x: 80 + (count % 4) * 220, y: 80 + Math.floor(count / 4) * 200, + groupId: resolveGroup(input.groupId, null), + binding: input.binding ?? noneBinding(), }); } return { ...p, assets, npcs }; @@ -1232,7 +1331,12 @@ export class ZipProjectStore { async updateNpcFields( npcId: NpcId, - patch: { name?: string; description?: string }, + patch: { + name?: string; + description?: string; + groupId?: NpcGroupId | null; + binding?: NpcBinding; + }, ): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); @@ -1250,12 +1354,20 @@ export class ZipProjectStore { } } await this.updateProject((p) => { + const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id)); const npcs = (p.npcs ?? []).map((n) => { if (n.id !== npcId) return n; + let groupId = n.groupId; + if (patch.groupId !== undefined) { + groupId = + patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null; + } return { ...n, ...(name !== undefined ? { name } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}), + groupId, + ...(patch.binding !== undefined ? { binding: patch.binding } : {}), }; }); return { ...p, npcs }; @@ -1313,6 +1425,101 @@ export class ZipProjectStore { return latest; } + async upsertNpcGroup(input: { + groupId?: NpcGroupId; + name: string; + color?: string; + parentId?: NpcGroupId | null; + }): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const name = input.name.trim(); + if (name.length < 1) throw new Error('Group name is required'); + const color = normalizeHexColor(input.color, DEFAULT_NPC_GROUP_COLOR); + await this.updateProject((p) => { + const groups = [...(p.npcGroups ?? [])]; + const editingId = input.groupId ?? null; + const parentId = + input.parentId === undefined + ? editingId + ? (groups.find((g) => g.id === editingId)?.parentId ?? null) + : null + : input.parentId; + if (parentId && !groups.some((g) => g.id === parentId)) { + throw new Error('Parent group not found'); + } + if (editingId && parentId && wouldCreateGroupCycle(groups, editingId, parentId)) { + throw new Error('Invalid group parent'); + } + const nameKey = name.toLowerCase(); + const siblingConflict = groups.some( + (g) => + g.id !== editingId && + g.parentId === parentId && + g.name.trim().toLowerCase() === nameKey, + ); + if (siblingConflict) throw new Error('Group name already exists'); + + if (editingId) { + const idx = groups.findIndex((g) => g.id === editingId); + if (idx < 0) throw new Error('Group not found'); + groups[idx] = { ...groups[idx]!, name, color, parentId }; + } else { + groups.push({ + id: asNpcGroupId(`ng_${this.randomId()}`), + name, + color, + parentId, + }); + } + return { ...p, npcGroups: groups }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async deleteNpcGroup(groupId: NpcGroupId): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + await this.updateProject((p) => { + const groups = p.npcGroups ?? []; + if (!groups.some((g) => g.id === groupId)) throw new Error('Group not found'); + const parentOfDeleted = groups.find((g) => g.id === groupId)?.parentId ?? null; + const nextGroups = groups + .filter((g) => g.id !== groupId) + .map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g)); + const npcs = (p.npcs ?? []).map((n) => + n.groupId === groupId ? { ...n, groupId: null } : n, + ); + return { ...p, npcGroups: nextGroups, npcs }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async setNpcGroupsOrder(groupIds: NpcGroupId[]): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + await this.updateProject((p) => { + const byId = new Map((p.npcGroups ?? []).map((g) => [g.id, g])); + const next: ProjectNpcGroup[] = []; + for (const id of groupIds) { + const g = byId.get(id); + if (g) { + next.push(g); + byId.delete(id); + } + } + for (const g of byId.values()) next.push(g); + return { ...p, npcGroups: next }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + async upsertNpcRelation(input: { relationId?: NpcRelationId; sourceNpcId: NpcId; @@ -1583,6 +1790,50 @@ export class ZipProjectStore { return opened; } + /** + * Импорт мира/модуля Foundry VTT (папка или .zip/.fvtt) в новый проект. + * Создаёт `.ttrpg.zip`, открывает проект и возвращает его. + */ + async importProjectFromFoundry( + sourcePath: string, + onProgress?: (p: FoundryImportProgress) => void, + ): Promise { + await this.ensureRoots(); + const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress); + try { + this.projectSession += 1; + const projectId = asProjectId(this.randomId()); + const cacheDir = path.join(getProjectsCacheRootDir(), projectId); + await fs.rm(cacheDir, { recursive: true, force: true }); + await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true }); + + const { project } = await buildProjectFromFoundryDocuments( + loaded.manifest, + loaded.docs, + cacheDir, + onProgress, + { projectId }, + ); + + const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(project.meta.fileBaseName)); + const projectPath = path.join(cacheDir, 'project.json'); + this.openProject = { + id: project.id, + zipPath, + cacheDir, + projectPath, + project, + }; + await this.writeCacheProject(cacheDir, project); + onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' }); + await this.enqueuePack(cacheDir, zipPath); + onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' }); + return this.openProject.project; + } finally { + await loaded.cleanup(); + } + } + /** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */ async exportProjectZipToPath( projectId: ProjectId, @@ -1809,6 +2060,7 @@ export class ZipProjectStore { selections: StorylineSelection[], sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, + npcResolutions?: NpcImportResolution[], ): Promise<{ project: Project; report: StorylineImportMergeReport }> { if (!this.openProject) throw new Error('Нет открытого проекта'); const offsetX = computeGraphImportOffsetX(this.openProject.project); @@ -1817,7 +2069,10 @@ export class ZipProjectStore { source, selections, sceneResolutions, - { graphOffsetX: offsetX }, + { + graphOffsetX: offsetX, + ...(npcResolutions ? { npcResolutions } : {}), + }, ); const targetCache = this.openProject.cacheDir; @@ -1851,6 +2106,7 @@ export class ZipProjectStore { selections: StorylineSelection[], sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, + npcResolutions?: NpcImportResolution[], ): Promise<{ project: Project; report: StorylineImportMergeReport }> { this.assertStorylineMergeAllowed(selections); const snap = await this.loadProjectSnapshot(sourceProjectId); @@ -1861,6 +2117,7 @@ export class ZipProjectStore { selections, sceneResolutions, onProgress, + npcResolutions, ); } finally { if (snap.ownsCache) { @@ -1874,6 +2131,7 @@ export class ZipProjectStore { selections: StorylineSelection[], sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, + npcResolutions?: NpcImportResolution[], ): Promise<{ project: Project; report: StorylineImportMergeReport }> { this.assertStorylineMergeAllowed(selections); const source = await this.readExternalProjectForImport(sourcePath); @@ -1887,6 +2145,7 @@ export class ZipProjectStore { selections, sceneResolutions, onProgress, + npcResolutions, ); } finally { await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined); @@ -2095,6 +2354,13 @@ function normalizeProject(p: Project): Project { (x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } => Boolean(x), ); + const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups); + const groupIdSet = new Set(npcGroups.map((g) => g.id)); + const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]); + const sideStartIds = new Set( + sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id), + ); + const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene); const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs; const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : []) .map((n, index) => { @@ -2106,6 +2372,8 @@ function normalizeProject(p: Project): Project { description?: string; x?: number; y?: number; + groupId?: string | null; + binding?: unknown; }; if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null; const name = obj.name.trim(); @@ -2120,6 +2388,12 @@ function normalizeProject(p: Project): Project { description: typeof obj.description === 'string' ? obj.description : '', x, y, + groupId: resolveNpcGroupId(obj.groupId, groupIdSet), + binding: normalizeNpcBinding(obj.binding, { + sceneIds: sceneIdSet, + sideStartIds, + hasMainStart, + }), }; }) .filter((x): x is ProjectNpc => Boolean(x)); @@ -2171,6 +2445,7 @@ function normalizeProject(p: Project): Project { campaignAudios, materials, npcs, + npcGroups, npcRelations, sceneGraphNodes, sceneGraphEdges, diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 4e1e8ec..4d9f560 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -153,7 +153,8 @@ display: flex; align-items: center; justify-content: center; - z-index: 10000; + /* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */ + z-index: 30000; } .editorLockOverlay { @@ -380,9 +381,14 @@ width: 100%; box-sizing: border-box; padding: 8px 10px; + padding-right: 28px; border-radius: var(--radius-sm); border: 1px solid var(--stroke); - background: var(--bg0); + background-color: var(--bg0); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 12px 12px; color: var(--text0); font: inherit; } diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index d60d075..a59dc74 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder'; import type { + NpcImportResolution, SceneImportResolution, StorylineImportMergeReport, StorylineSelection, @@ -42,6 +43,7 @@ import { sceneTitleFromMediaPath, useFileDropZone, } from './fileDrop'; +import { FoundryImportModal } from './FoundryImportModal'; import { buildNextSceneCardById } from './graph/sceneCardById'; import { DND_SCENE_ID_MIME, @@ -58,12 +60,15 @@ import { SceneDescriptionModal } from './SceneDescriptionModal'; import type { ProjectNoticeCode } from './state/projectState'; import { useProjectState } from './state/projectState'; import { + buildNpcResolutionsForImport, buildSceneResolutionsForImport, computeImportConflicts, + computeNpcImportConflicts, ExportProjectModal, ImportReportModal, ImportSourceModal, ImportStorylinesModal, + NpcConflictModal, SceneConflictModal, useStorylineLabels, type ImportPeekResult, @@ -123,11 +128,17 @@ export function EditorApp() { const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [importSourceOpen, setImportSourceOpen] = useState(false); + const [foundryImportOpen, setFoundryImportOpen] = useState(false); const [importPeek, setImportPeek] = useState(null); const [importStorylinesOpen, setImportStorylinesOpen] = useState(false); const [importConflictsOpen, setImportConflictsOpen] = useState(false); const [importConflicts, setImportConflicts] = useState>([]); + const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false); + const [importNpcConflicts, setImportNpcConflicts] = useState< + ReturnType + >([]); const [pendingImportSelections, setPendingImportSelections] = useState([]); + const [pendingSceneResolutions, setPendingSceneResolutions] = useState([]); const [importReportOpen, setImportReportOpen] = useState(false); const [importReport, setImportReport] = useState(null); const [previewDialogSceneId, setPreviewDialogSceneId] = useState(null); @@ -499,22 +510,71 @@ export function EditorApp() { [actions, storylineLabels], ); + const clearImportFlow = useCallback(() => { + setImportPeek(null); + setImportStorylinesOpen(false); + setImportConflictsOpen(false); + setImportNpcConflictsOpen(false); + setPendingImportSelections([]); + setPendingSceneResolutions([]); + setImportConflicts([]); + setImportNpcConflicts([]); + }, []); + const runStorylineMerge = useCallback( - async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => { + async ( + selections: StorylineSelection[], + sceneResolutions: SceneImportResolution[], + npcResolutions: NpcImportResolution[], + ) => { if (!importPeek) return; const { report } = importPeek.kind === 'project' && importPeek.sourceProjectId - ? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions) - : await actions.mergeImportZip(importPeek.filePath!, selections, resolutions); + ? await actions.mergeImportFromProject( + importPeek.sourceProjectId, + selections, + sceneResolutions, + npcResolutions, + ) + : await actions.mergeImportZip( + importPeek.filePath!, + selections, + sceneResolutions, + npcResolutions, + ); setImportReport(report); setImportReportOpen(true); - setImportPeek(null); - setImportStorylinesOpen(false); - setImportConflictsOpen(false); - setPendingImportSelections([]); - setImportConflicts([]); + clearImportFlow(); }, - [actions, importPeek], + [actions, clearImportFlow, importPeek], + ); + + const continueImportAfterScenes = useCallback( + (selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => { + if (!importPeek || !state.project) return; + const npcConflicts = computeNpcImportConflicts( + state.project, + importPeek.sourceProject, + selections, + ); + setPendingImportSelections(selections); + setPendingSceneResolutions(sceneResolutions); + setImportConflictsOpen(false); + if (npcConflicts.length > 0) { + setImportNpcConflicts(npcConflicts); + setImportNpcConflictsOpen(true); + return; + } + const npcResolutions = buildNpcResolutionsForImport( + state.project, + importPeek.sourceProject, + selections, + [], + [], + ); + void runStorylineMerge(selections, sceneResolutions, npcResolutions); + }, + [importPeek, runStorylineMerge, state.project], ); const handleImportSourceContinue = useCallback( @@ -554,10 +614,16 @@ export function EditorApp() { setImportSourceOpen(true); }, []); + const handleImportFoundry = useCallback(() => { + setProjectMenuOpen(false); + setFoundryImportOpen(true); + }, []); + const goHome = useCallback(() => { setProjectMenuOpen(false); setExportModalOpen(false); setImportSourceOpen(false); + setFoundryImportOpen(false); setImportStorylinesOpen(false); setImportConflictsOpen(false); setImportReportOpen(false); @@ -1294,6 +1360,18 @@ export function EditorApp() { > {t('projectMenu.import')} + {!state.project ? ( + + ) : null} + + +
+
{t('foundryImport.hint')}
+ +
{t('foundryImport.sourceType')}
+ + +
{t('foundryImport.source')}
+
+ + {picked ? picked.name : t('foundryImport.noSourceSelected')} +
+
+ + {error ?
{error}
: null} + +
+ + +
+ + , + document.body, + ); +} diff --git a/app/renderer/editor/StorylineTransferModals.tsx b/app/renderer/editor/StorylineTransferModals.tsx index 1c9386a..fdf5e56 100644 --- a/app/renderer/editor/StorylineTransferModals.tsx +++ b/app/renderer/editor/StorylineTransferModals.tsx @@ -3,8 +3,12 @@ import { createPortal } from 'react-dom'; import { collectSceneIdsForSelections, + filterNpcsForStorylineExport, + findNpcNameConflicts, findSceneTitleConflicts, storylineSelectionKey, + type NpcImportResolution, + type NpcNameConflict, type SceneImportResolution, type SceneTitleConflict, type StorylineImportMergeReport, @@ -14,8 +18,9 @@ import { } from '../../shared/graph/storylineExportImport'; import type { Project, ProjectId, SceneId } from '../../shared/types'; import { Button } from '../shared/ui/controls'; -import { useEditorI18n } from './i18n/EditorI18nContext'; + import styles from './EditorApp.module.css'; +import { useEditorI18n } from './i18n/EditorI18nContext'; type ExportProjectModalProps = { open: boolean; @@ -109,11 +114,21 @@ export function ExportProjectModal({ return createPortal( <> - @@ -145,7 +160,10 @@ export function ExportProjectModal({ const checked = selectedKeys.has(key); const disabled = item.disabled === true; return ( -