import * as plugins from './moxytool.plugins.ts'; import * as paths from './moxytool.paths.ts'; import { logger } from './moxytool.logging.ts'; /** * Interface for script metadata from JSON files */ export interface IScriptMetadata { name: string; slug: string; type: string; categories: number[]; description: string; install_methods: Array<{ type: string; script: string; resources: { cpu?: number; ram?: number; hdd?: number; os?: string; version?: string; }; }>; interface_port?: number; config_path?: string; documentation?: string; website?: string; logo?: string; default_credentials?: { username?: string; password?: string; }; notes?: Array<{ type: string; content: string; }>; updateable?: boolean; privileged?: boolean; date_created?: string; } /** * Interface for the cached index */ export interface IScriptIndexCache { lastUpdated: number; scripts: IScriptMetadata[]; } /** * ScriptIndex class manages the Proxmox community scripts index * - Fetches JSON metadata from Gitea * - Caches locally with 24-hour TTL * - Provides search and filtering capabilities */ export class ScriptIndex { private static readonly BASE_URL = 'https://code.foss.global/asset_backups/ProxmoxVE/raw/branch/main/frontend/public/json'; private static readonly INDEX_LIST_URL = 'https://code.foss.global/asset_backups/ProxmoxVE/src/branch/main/frontend/public/json'; private static readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours in ms private cache: IScriptIndexCache | null = null; /** * Check if the index needs to be refreshed (>24 hours old) */ public async needsRefresh(): Promise { try { // Try to load from cache await this.loadCache(); if (!this.cache) { return true; // No cache, need refresh } const age = Date.now() - this.cache.lastUpdated; return age > ScriptIndex.CACHE_TTL; } catch (error) { logger.log('warn', `Error checking cache age: ${error}`); return true; // On error, refresh } } /** * Load the index from local cache */ public async loadCache(): Promise { try { if (!Deno) { throw new Error('Deno runtime not available'); } const cacheFile = paths.scriptsIndexFile; // Check if cache file exists try { await Deno.stat(cacheFile); } catch { // File doesn't exist this.cache = null; return; } // Read and parse cache const content = await Deno.readTextFile(cacheFile); this.cache = JSON.parse(content) as IScriptIndexCache; logger.log('info', `Loaded ${this.cache.scripts.length} scripts from cache`); } catch (error) { logger.log('warn', `Error loading cache: ${error}`); this.cache = null; } } /** * Fetch the index from Gitea and update cache */ public async fetchIndex(): Promise { try { logger.log('info', 'Fetching script index from Gitea...'); // First, get the list of all JSON files const fileList = await this.fetchFileList(); if (fileList.length === 0) { throw new Error('No JSON files found in repository'); } logger.log('info', `Found ${fileList.length} script definitions`); // Fetch all JSON files const scripts: IScriptMetadata[] = []; let successCount = 0; let errorCount = 0; for (const filename of fileList) { try { const script = await this.fetchScript(filename); if (script) { scripts.push(script); successCount++; } } catch (error) { errorCount++; logger.log('warn', `Failed to fetch ${filename}: ${error}`); } } logger.log('info', `Successfully fetched ${successCount} scripts (${errorCount} errors)`); // Update cache this.cache = { lastUpdated: Date.now(), scripts, }; // Save to disk await this.saveCache(); logger.log('success', `Index refreshed: ${scripts.length} scripts cached`); } catch (error) { logger.log('error', `Failed to fetch index: ${error}`); throw error; } } /** * Fetch the list of JSON files from the repository */ private async fetchFileList(): Promise { try { // Simple approach: fetch a known comprehensive list // In production, you'd parse the directory listing or use the API // For now, we'll use a hardcoded list of common scripts // TODO: Implement proper directory listing scraping or use Gitea API // Fetch the directory listing page const response = await fetch(ScriptIndex.INDEX_LIST_URL); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const html = await response.text(); // Extract .json filenames from HTML const jsonFileRegex = /href="[^"]*\/([^"\/]+\.json)"/g; const matches = [...html.matchAll(jsonFileRegex)]; const files = matches.map((match) => match[1]).filter((file) => file.endsWith('.json')); // Remove duplicates return [...new Set(files)]; } catch (error) { logger.log('error', `Failed to fetch file list: ${error}`); throw error; } } /** * Fetch a single script JSON file */ private async fetchScript(filename: string): Promise { try { const url = `${ScriptIndex.BASE_URL}/${filename}`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return data as IScriptMetadata; } catch (error) { logger.log('warn', `Failed to fetch ${filename}: ${error}`); return null; } } /** * Save the cache to disk */ private async saveCache(): Promise { try { if (!this.cache) { return; } // Ensure cache directory exists await Deno.mkdir(paths.scriptsCacheDir, { recursive: true }); // Write cache file const content = JSON.stringify(this.cache, null, 2); await Deno.writeTextFile(paths.scriptsIndexFile, content); logger.log('info', 'Cache saved successfully'); } catch (error) { logger.log('error', `Failed to save cache: ${error}`); throw error; } } /** * Get all scripts from cache */ public getAll(): IScriptMetadata[] { if (!this.cache) { return []; } return this.cache.scripts; } /** * Search scripts by query string */ public search(query: string): IScriptMetadata[] { if (!this.cache) { return []; } const lowerQuery = query.toLowerCase(); return this.cache.scripts.filter((script) => { // Search in name, description, and slug return ( script.name.toLowerCase().includes(lowerQuery) || script.slug.toLowerCase().includes(lowerQuery) || script.description.toLowerCase().includes(lowerQuery) ); }); } /** * Get a script by slug */ public getBySlug(slug: string): IScriptMetadata | null { if (!this.cache) { return null; } return this.cache.scripts.find((script) => script.slug === slug) || null; } /** * Filter scripts by type (ct/vm) */ public filterByType(type: string): IScriptMetadata[] { if (!this.cache) { return []; } return this.cache.scripts.filter((script) => script.type === type); } /** * Get cache statistics */ public getStats(): { count: number; lastUpdated: Date | null; age: string } { if (!this.cache) { return { count: 0, lastUpdated: null, age: 'never' }; } const now = Date.now(); const age = now - this.cache.lastUpdated; const hours = Math.floor(age / (60 * 60 * 1000)); const minutes = Math.floor((age % (60 * 60 * 1000)) / (60 * 1000)); return { count: this.cache.scripts.length, lastUpdated: new Date(this.cache.lastUpdated), age: hours > 0 ? `${hours}h ${minutes}m ago` : `${minutes}m ago`, }; } }