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; }