import { randomUUID } from 'node:crypto'; import { classicRegularIconNames } from '#lib/icons/classic-regular.js'; import { getDatabase } from './database.js'; export type Tool = { id: string; name: string; startUrl: string; description: string; iconName: string; isActive: boolean; formatIds: string[]; }; export type ToolInput = Omit; function clean(value: unknown, maximum: number, required = false) { const text = String(value ?? '').trim(); if (text.length > maximum || (required && !text)) throw new Error('INVALID_TOOL'); return text; } function validUrl(value: string) { if (!value) return ''; try { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error(); return url.href; } catch { throw new Error('INVALID_TOOL_URL'); } } function validated(input: ToolInput): ToolInput { const formatIds = [...new Set(input.formatIds.filter(Boolean))]; if (formatIds.length > 30) throw new Error('INVALID_TOOL'); if (formatIds.length) { const placeholders = formatIds.map(() => '?').join(', '); const row = getDatabase() .prepare(`SELECT count(*) AS count FROM formats WHERE id IN (${placeholders})`) .get(...formatIds) as { count: number }; if (row.count !== formatIds.length) throw new Error('INVALID_TOOL_FORMAT'); } return { name: clean(input.name, 120, true), startUrl: validUrl(clean(input.startUrl, 2_000)), description: clean(input.description, 2_000), iconName: (() => { const iconName = clean(input.iconName, 120, true); if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON'); return iconName; })(), isActive: Boolean(input.isActive), formatIds }; } export function listTools(includeInactive = false): Tool[] { const database = getDatabase(); const rows = database .prepare( `SELECT id, name, start_url, description, icon_name, is_active FROM tools ${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name` ) .all() as { id: string; name: string; start_url: string; description: string; icon_name: string; is_active: number; }[]; const formats = database.prepare( 'SELECT format_id FROM tool_formats WHERE tool_id = ? ORDER BY format_id' ); return rows.map((row) => ({ id: row.id, name: row.name, startUrl: row.start_url, description: row.description, iconName: row.icon_name, isActive: row.is_active === 1, formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id) })); } function replaceFormats(toolId: string, formatIds: string[]) { const database = getDatabase(); database.prepare('DELETE FROM tool_formats WHERE tool_id = ?').run(toolId); const insert = database.prepare('INSERT INTO tool_formats (tool_id, format_id) VALUES (?, ?)'); for (const formatId of formatIds) insert.run(toolId, formatId); } export function createTool(rawInput: ToolInput) { const input = validated(rawInput); const database = getDatabase(); const id = randomUUID(); const now = new Date().toISOString(); try { database.transaction(() => { database .prepare( `INSERT INTO tools (id, name, start_url, description, icon_name, is_active, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, input.name, input.startUrl, input.description, input.iconName, input.isActive ? 1 : 0, now, now ); replaceFormats(id, input.formatIds); })(); } catch (cause) { if (cause instanceof Error && cause.message.includes('UNIQUE')) { throw new Error('TOOL_EXISTS', { cause }); } throw cause; } return id; } export function updateTool(id: string, rawInput: ToolInput) { const input = validated(rawInput); const database = getDatabase(); try { database.transaction(() => { const result = database .prepare( `UPDATE tools SET name = ?, start_url = ?, description = ?, icon_name = ?, is_active = ?, updated_at_utc = ? WHERE id = ?` ) .run( input.name, input.startUrl, input.description, input.iconName, input.isActive ? 1 : 0, new Date().toISOString(), id ); if (result.changes !== 1) throw new Error('NOT_FOUND'); replaceFormats(id, input.formatIds); })(); } catch (cause) { if (cause instanceof Error && cause.message.includes('UNIQUE')) { throw new Error('TOOL_EXISTS', { cause }); } throw cause; } }