const express = require('express'); const fs = require('fs').promises; const path = require('path'); const paths = require('../utils/paths'); const ConfigParser = require('../utils/config-parser'); const ContentDBClient = require('../utils/contentdb'); const ContentDBUrlParser = require('../utils/contentdb-url'); const PackageRegistry = require('../utils/package-registry'); const router = express.Router(); const contentdb = new ContentDBClient(); const packageRegistry = new PackageRegistry(); // Initialize package registry packageRegistry.init().catch(console.error); // Main Extensions page - shows installed content and installer router.get('/', async (req, res) => { try { paths.ensureDirectories(); // Get installed packages from registry (games, mods, texture packs) const allRegistryPackages = await packageRegistry.getAllInstallations(); const statistics = await packageRegistry.getStatistics(); // Filter registry packages to only include those that actually exist on disk const installedPackages = []; for (const pkg of allRegistryPackages) { let packagePath; if (pkg.package_type === 'game') { packagePath = paths.getGamePath(pkg.name); } else if (pkg.package_type === 'mod') { packagePath = paths.getModPath(pkg.name); } else { // For other types, assume they exist (texture packs, etc.) installedPackages.push(pkg); continue; } // Only include if the package directory actually exists try { const stats = await fs.stat(packagePath); if (stats.isDirectory()) { installedPackages.push(pkg); } } catch (error) { // Package directory doesn't exist, don't include it console.log(`Package ${pkg.name} (${pkg.package_type}) not found at ${packagePath}, excluding from installed list`); } } // Get local mods (not from ContentDB) let localMods = []; try { const modDirs = await fs.readdir(paths.modsDir); for (const modDir of modDirs) { try { const modPath = paths.getModPath(modDir); const configPath = paths.getModConfigPath(modDir); const stats = await fs.stat(modPath); if (!stats.isDirectory()) continue; // Check if this mod is already in the registry (from ContentDB) const isFromContentDB = installedPackages.some(pkg => pkg.name === modDir && pkg.install_location === 'global' ); if (!isFromContentDB) { const config = await ConfigParser.parseModConfig(configPath); localMods.push({ name: modDir, title: config.title || modDir, description: config.description || '', author: config.author || 'Local', type: 'mod', location: 'global', source: 'local', path: modPath, lastModified: stats.mtime }); } } catch (modError) { console.error(`Error reading mod ${modDir}:`, modError); } } } catch (dirError) { console.warn('Could not read mods directory:', dirError); } // Get installed games from all locations (only those NOT already in ContentDB registry) let localGames = []; try { const allInstalledGames = await paths.getInstalledGames(); for (const game of allInstalledGames) { // Check if this game is already in the ContentDB registry const isFromContentDB = installedPackages.some(pkg => (pkg.name === game.name || pkg.name === game.directoryName) && pkg.package_type === 'game' ); if (!isFromContentDB) { localGames.push({ name: game.name, title: game.title, description: game.description, author: game.author || 'Unknown', type: 'game', location: 'games', source: game.isSystemGame ? 'system' : 'local', path: game.path, lastModified: null // We don't have this info from the paths util }); } } } catch (dirError) { console.warn('Could not read games:', dirError); } // Combine all content (ContentDB packages already include games) const allContent = [ ...installedPackages.map(pkg => ({ ...pkg, source: 'contentdb' })), ...localMods, ...localGames ]; // Sort by type (games first, then mods, then texture packs) and name const sortOrder = { game: 1, mod: 2, txp: 3 }; allContent.sort((a, b) => { const typeA = sortOrder[a.package_type || a.type] || 4; const typeB = sortOrder[b.package_type || b.type] || 4; if (typeA !== typeB) return typeA - typeB; return (a.title || a.name).localeCompare(b.title || b.name); }); res.render('extensions/index', { title: 'Extensions', allContent: allContent, statistics: { ...statistics, games: installedPackages.filter(pkg => pkg.package_type === 'game').length + localGames.length, local_mods: localMods.length }, currentPage: 'extensions' }); } catch (error) { console.error('Error loading extensions:', error); res.status(500).render('error', { error: 'Failed to load extensions', message: error.message }); } }); // Install package from URL (same as ContentDB) router.post('/install-url', async (req, res) => { try { const { packageUrl, installLocation, worldName, installDeps } = req.body; if (!packageUrl) { return res.status(400).json({ success: false, error: 'Package URL is required' }); } // Parse and validate URL const parsed = ContentDBUrlParser.parseUrl(packageUrl); if (!parsed.isValid) { return res.status(400).json({ success: false, error: parsed.error || 'Invalid URL format' }); } const { author, name } = parsed; // Get package info to determine type const packageInfo = await contentdb.getPackage(author, name); const packageType = packageInfo.type || 'mod'; // Determine target path based on package type let targetPath; let locationDescription; if (packageType === 'game') { await fs.mkdir(paths.gamesDir, { recursive: true }); targetPath = paths.getGamePath(name); locationDescription = 'games directory'; } else if (packageType === 'txp') { await fs.mkdir(paths.texturesDir, { recursive: true }); targetPath = path.join(paths.texturesDir, name); locationDescription = 'textures directory'; } else { if (installLocation === 'world') { if (!worldName) { return res.status(400).json({ success: false, error: 'World name is required when installing to specific world' }); } if (!paths.isValidWorldName(worldName)) { return res.status(400).json({ success: false, error: 'Invalid world name' }); } const worldModsPath = paths.getWorldModsPath(worldName); await fs.mkdir(worldModsPath, { recursive: true }); targetPath = path.join(worldModsPath, name); locationDescription = `world "${worldName}"`; } else { await fs.mkdir(paths.modsDir, { recursive: true }); targetPath = path.join(paths.modsDir, name); locationDescription = 'global directory'; } } // Check if already installed let installLocationKey; if (packageType === 'game') { installLocationKey = 'games'; } else if (packageType === 'txp') { installLocationKey = 'textures'; } else { installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global'; } const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey); if (isInstalled) { return res.status(409).json({ success: false, error: `Package "${name}" is already installed in ${locationDescription}` }); } // Install the package let installResult; if (installDeps === 'on' && packageType === 'mod') { const basePath = installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir; installResult = await contentdb.installPackageWithDeps(author, name, basePath, true); if (installResult.errors && installResult.errors.length > 0) { console.warn('Installation completed with errors:', installResult.errors); } } else { installResult = await contentdb.downloadPackage(author, name, targetPath); } // Record installation in registry try { const packageInfo = installResult.main ? installResult.main.package : installResult.package; const releaseInfo = installResult.main ? installResult.main.release : installResult.release; await packageRegistry.recordInstallation({ author: author, name: name, version: releaseInfo?.title || 'latest', releaseId: releaseInfo?.id, installLocation: installLocationKey, installPath: targetPath, contentdbUrl: parsed.fullUrl, packageType: packageInfo?.type || 'mod', title: packageInfo?.title || name, shortDescription: packageInfo?.short_description || '', dependencies: packageInfo?.hard_dependencies || [] }); // Record dependencies if installed if (installDeps === 'on' && installResult.dependencies) { for (const dep of installResult.dependencies) { const depInfo = dep.package; const depRelease = dep.release; const depPath = path.join( installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir, depInfo.name ); await packageRegistry.recordInstallation({ author: depInfo.author, name: depInfo.name, version: depRelease?.title || 'latest', releaseId: depRelease?.id, installLocation: installLocationKey, installPath: depPath, contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`, packageType: depInfo.type || 'mod', title: depInfo.title || depInfo.name, shortDescription: depInfo.short_description || '', dependencies: depInfo.hard_dependencies || [] }); } } } catch (registryError) { console.warn('Failed to record installation in registry:', registryError); } // Create success response let message = `Successfully installed "${name}" to ${locationDescription}`; if (installDeps === 'on' && installResult.dependencies) { const depCount = installResult.dependencies.length; if (depCount > 0) { message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`; } } res.json({ success: true, message: message, package: { author: author, name: name, location: locationDescription }, installResult: installResult }); } catch (error) { console.error('Error installing package from URL:', error); res.status(500).json({ success: false, error: 'Installation failed: ' + error.message }); } }); // API endpoint for search (AJAX) router.get('/api/search', async (req, res) => { try { const { q = '', type = '', sort = 'score', order = 'desc', limit = '10' } = req.query; const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0); res.json({ packages: packages || [], query: q, type: type }); } catch (error) { console.error('Error searching ContentDB:', error); res.status(500).json({ error: error.message }); } }); module.exports = router;