348 lines
9.0 KiB
TypeScript
348 lines
9.0 KiB
TypeScript
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 ObjectSorter for fuzzy searching across multiple fields
|
|
const sorter = new plugins.smartfuzzy.ObjectSorter(scripts);
|
|
|
|
// Search across slug, name, and description
|
|
const results = sorter.sort(query, ['slug', 'name', 'description']);
|
|
|
|
// Post-process to weight results by which field matched
|
|
const weightedResults = results.map((result) => {
|
|
let weight = 1;
|
|
|
|
// Boost score if match was in slug (highest priority)
|
|
if (result.matches?.some((m) => m.key === 'slug')) {
|
|
weight = 3;
|
|
}
|
|
// Boost if match was in name (medium priority)
|
|
else if (result.matches?.some((m) => m.key === 'name')) {
|
|
weight = 2;
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
adjustedScore: (result.score || 0) / weight,
|
|
};
|
|
});
|
|
|
|
// Sort by adjusted score and return just the script objects
|
|
return weightedResults
|
|
.sort((a, b) => (a.adjustedScore || 0) - (b.adjustedScore || 0))
|
|
.map((result) => result.item);
|
|
}
|
|
|
|
/**
|
|
* 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`,
|
|
};
|
|
}
|
|
}
|