157 lines
4.4 KiB
TypeScript
157 lines
4.4 KiB
TypeScript
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<Tool, 'id'>;
|
|
|
|
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;
|
|
}
|
|
}
|