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]), }; }