// Liest den scenes/-Ordner ein und stellt die Kategorie-Metadaten bereit. // Eine "Kategorie" = ein Unterordner von scenes/. Der Scan passiert einmal beim // Serverstart; ein neuer Ordner wird also erst nach einem Neustart sichtbar. import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; import { join } from 'node:path'; const IMAGE_EXTENSIONS = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif']; function isImage(filename) { const lower = filename.toLowerCase(); return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)); } function listImages(dir) { if (!existsSync(dir)) return []; return readdirSync(dir) .filter((name) => isImage(name)) .sort(); } function readConfig(categoryDir) { const configPath = join(categoryDir, 'config.json'); if (!existsSync(configPath)) return {}; try { return JSON.parse(readFileSync(configPath, 'utf8')); } catch (err) { console.warn(`[scanner] config.json in ${categoryDir} ist ungueltig:`, err.message); return {}; } } // Liest die gemeinsame Library (Fundgrube): library/props/ (Deko) + library/feet/ // (alle Fuesse). Daraus generiert der Server normale, nicht-thematische Szenen. export function scanLibrary(libraryRoot) { const propsDir = join(libraryRoot, 'props'); const feetDir = join(libraryRoot, 'feet'); const props = listImages(propsDir).map((f) => `/library/props/${f}`); const feet = listImages(feetDir).map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `/library/feet/${f}`, })); if (feet.length < 3) { console.warn(`[scanner] Library hat < 3 Fuesse (${feet.length}) — normale Szenen deaktiviert.`); return null; } if (props.length < 1) { console.warn('[scanner] Library hat keine Deko in props/ — normale Szenen deaktiviert.'); return null; } return { props, feet }; } // Baut die Liste aller SPEZIAL-Level (Unterordner von scenes/). Ungueltige Ordner // werden mit einer Warnung uebersprungen, statt den Start zu blockieren. export function scanScenes(scenesRoot) { if (!existsSync(scenesRoot)) { console.warn(`[scanner] scenes/-Ordner fehlt: ${scenesRoot}`); return []; } const categories = []; for (const entry of readdirSync(scenesRoot)) { const categoryDir = join(scenesRoot, entry); if (!statSync(categoryDir).isDirectory()) continue; const config = readConfig(categoryDir); const type = config.type || 'clipart'; const name = config.name || entry; if (type === 'emoji') { const targets = Array.isArray(config.targets) ? config.targets : []; const props = Array.isArray(config.props) ? config.props : []; if (targets.length < 1 || props.length < 3) { console.warn(`[scanner] "${entry}" (emoji) uebersprungen: braucht targets + genug props in config.json`); continue; } categories.push({ id: entry, name, type, background: config.background || '#f4f4f8', propCount: config.propCount || 250, minSize: config.minSize || 42, maxSize: config.maxSize || 78, targetGlyphs: targets, propGlyphs: props, }); continue; } // clipart / photo: Bilddateien aus props/ und targets/ const propsDir = join(categoryDir, 'props'); const targetsDir = join(categoryDir, 'targets'); const props = listImages(propsDir).map((f) => `/scenes/${entry}/props/${f}`); const targets = listImages(targetsDir).map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `/scenes/${entry}/targets/${f}`, })); if (targets.length < 3) { console.warn(`[scanner] "${entry}" uebersprungen: braucht mind. 3 Bilder in targets/ (gefunden: ${targets.length})`); continue; } if (props.length < 1) { console.warn(`[scanner] "${entry}" uebersprungen: keine Bilder in props/`); continue; } categories.push({ id: entry, name, type, background: config.background || '#f4f4f8', propCount: config.propCount || 60, minSize: config.minSize || 70, maxSize: config.maxSize || 130, props, targets, }); } return categories; } // "ballerina-fuss.svg" -> "Ballerina Fuss" function prettyName(filename) { return filename .replace(/\.[^.]+$/, '') .replace(/[-_]+/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()); }