Files
moxytool/ts/moxytool.classes.scriptindex.ts

342 lines
8.9 KiB
TypeScript
Raw Permalink Normal View History

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<boolean> {
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<void> {
try {
// Don't reload if already cached
if (this.cache) {
return;
}
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<void> {
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<string[]> {
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<IScriptMetadata | null> {
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<void> {
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 with optional type filter
* @param query - Search query
* @param typeFilter - Optional type filter (e.g., 'vm', 'ct', 'pve')
*/
public search(query: string, typeFilter?: string): IScriptMetadata[] {
if (!this.cache) {
return [];
}
let scripts = this.cache.scripts;
// Apply type filter if provided
if (typeFilter) {
scripts = scripts.filter((script) => script.type === typeFilter);
}
// Use smartfuzzy for ranking
const fuzzyMatcher = new plugins.smartfuzzy.FuzzyMatcher();
const scoredResults = scripts.map((script) => {
// Calculate match scores for each field
const nameScore = script.name ? fuzzyMatcher.match(query, script.name) : 0;
const slugScore = script.slug ? fuzzyMatcher.match(query, script.slug) : 0;
const descScore = script.description ? fuzzyMatcher.match(query, script.description) : 0;
// Prioritize: slug > name > description
const totalScore = slugScore * 3 + nameScore * 2 + descScore;
return {
script,
score: totalScore,
};
});
// Filter out non-matches and sort by score
return scoredResults
.filter((result) => result.score > 0)
.sort((a, b) => b.score - a.score)
.map((result) => result.script);
}
/**
* 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`,
};
}
}