import fs from "node:fs"; import path from "node:path"; export type SQLiteColumnType = "TEXT" | "INTEGER" | "REAL" | "BOOLEAN"; export interface SQLiteColumnDefinition { name: string; type: SQLiteColumnType; notNull?: boolean; defaultValue?: string | number | null; editable?: boolean; primaryKey?: boolean; } export interface CategoryTemplate { name: string; columns: SQLiteColumnDefinition[]; imagePath: string; } const BASE_COLUMNS: SQLiteColumnDefinition[] = [ { name: "id", type: "INTEGER", primaryKey: true, notNull: true, editable: false, }, { name: "nome", type: "TEXT", notNull: true, editable: true }, { name: "image_url", type: "TEXT", editable: true }, { name: "image_local_path", type: "TEXT", editable: true }, ]; const SQLITE_DB_PATH = path.join(process.cwd(), "data", "tibiawiki.db"); const IMAGES_ROOT = path.join(process.cwd(), "data", "images"); let cachedDatabase: any | null = null; function loadBetterSqlite(): any { // eslint-disable-next-line @typescript-eslint/no-var-requires const Database = require("better-sqlite3"); return Database; } function getDatabase(): any { if (cachedDatabase) { return cachedDatabase; } if (!fs.existsSync(SQLITE_DB_PATH)) { throw new Error("Banco SQLite tibiawiki.db não encontrado em data/"); } const Database = loadBetterSqlite(); cachedDatabase = new Database(SQLITE_DB_PATH, { fileMustExist: true }); return cachedDatabase; } function normalizeColumnType( type: SQLiteColumnType | string, ): SQLiteColumnType { const upper = type.toUpperCase(); switch (upper) { case "INTEGER": case "INT": return "INTEGER"; case "REAL": case "FLOAT": case "DOUBLE": return "REAL"; case "BOOLEAN": return "BOOLEAN"; default: return "TEXT"; } } function sqliteTypeFromColumn(column: SQLiteColumnDefinition): string { const type = normalizeColumnType(column.type); if (type === "BOOLEAN") { return "INTEGER"; } return type; } function ensureImageDirectory(category: string) { const dir = path.join(IMAGES_ROOT, category); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } function mapPragmaColumns( columns: Array<{ name: string; type: string; notnull: number; pk: number }>, ): SQLiteColumnDefinition[] { const mapped = columns.map((column) => { const baseMatch = BASE_COLUMNS.find((base) => base.name === column.name); return { name: column.name, type: normalizeColumnType(column.type as SQLiteColumnType), notNull: column.notnull === 1, primaryKey: column.pk === 1, editable: baseMatch ? baseMatch.editable : column.pk !== 1, } satisfies SQLiteColumnDefinition; }); return mapped; } export function listCategoryTemplates(): CategoryTemplate[] { const db = getDatabase(); const tables = db .prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", ) .all() as Array<{ name: string }>; return tables.map((table) => { const pragma = db .prepare(`PRAGMA table_info(${table.name})`) .all() as Array<{ name: string; type: string; notnull: number; pk: number; }>; const columns = mapPragmaColumns(pragma); ensureImageDirectory(table.name); return { name: table.name, columns, imagePath: path.join("data", "images", table.name), } satisfies CategoryTemplate; }); } function validateCategoryName(name: string) { if (!/^[a-z0-9_]+$/i.test(name)) { throw new Error( "Nome da categoria deve conter apenas letras, números ou sublinhado.", ); } } export interface CreateCategoryInput { name: string; columns?: SQLiteColumnDefinition[]; } export function createCategoryTemplate( input: CreateCategoryInput, ): CategoryTemplate { const db = getDatabase(); const name = input.name.trim().toLowerCase(); validateCategoryName(name); const existing = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") .get(name) as { name: string } | undefined; if (existing) { throw new Error("Categoria já existe."); } const userColumns = (input.columns ?? []).map((column) => ({ ...column, type: normalizeColumnType(column.type), })); const columnsSql = [ ...BASE_COLUMNS.map((column) => { const pieces = [column.name, sqliteTypeFromColumn(column)]; if (column.primaryKey) pieces.push("PRIMARY KEY AUTOINCREMENT"); if (column.notNull) pieces.push("NOT NULL"); return pieces.join(" "); }), ...userColumns.map((column) => { const pieces = [column.name, sqliteTypeFromColumn(column)]; if (column.notNull) pieces.push("NOT NULL"); if (column.defaultValue !== undefined && column.defaultValue !== null) { pieces.push(`DEFAULT ${JSON.stringify(column.defaultValue)}`); } return pieces.join(" "); }), ]; const createSql = `CREATE TABLE ${name} (${columnsSql.join(", ")})`; db.prepare(createSql).run(); ensureImageDirectory(name); return { name, columns: [...BASE_COLUMNS, ...userColumns], imagePath: path.join("data", "images", name), } satisfies CategoryTemplate; } export interface AddColumnInput { category: string; column: SQLiteColumnDefinition; } export function addColumnToCategory( input: AddColumnInput, ): SQLiteColumnDefinition { const db = getDatabase(); const name = input.category.trim().toLowerCase(); validateCategoryName(name); const column = { ...input.column, type: normalizeColumnType(input.column.type), } satisfies SQLiteColumnDefinition; const columnSqlParts = [column.name, sqliteTypeFromColumn(column)]; if (column.notNull) columnSqlParts.push("NOT NULL"); if (column.defaultValue !== undefined && column.defaultValue !== null) { columnSqlParts.push(`DEFAULT ${JSON.stringify(column.defaultValue)}`); } const sql = `ALTER TABLE ${name} ADD COLUMN ${columnSqlParts.join(" ")}`; db.prepare(sql).run(); return column; } export interface UpsertItemInput { category: string; data: Record; } function ensureServerColumn(category: string) { const db = getDatabase(); const normalized = category.trim().toLowerCase(); validateCategoryName(normalized); const pragma = db.prepare(`PRAGMA table_info(${normalized})`).all() as Array<{ name: string; type: string; notnull: number; pk: number; }>; const hasServer = pragma.some((col) => col.name === "server"); if (!hasServer) { // Adiciona coluna 'server' como TEXT opcional addColumnToCategory({ category: normalized, column: { name: "server", type: "TEXT", notNull: false, editable: true }, }); } } export function insertCategoryItem(input: UpsertItemInput): number { const db = getDatabase(); const category = input.category.trim().toLowerCase(); validateCategoryName(category); // Garante que a coluna 'server' exista na tabela de destino. try { ensureServerColumn(category); } catch {} const data = { ...input.data }; delete data.id; const keys = Object.keys(data); const placeholders = keys.map(() => "?").join(", "); const sql = `INSERT INTO ${category} (${keys.join(", ")}) VALUES (${placeholders})`; const values = keys.map((key) => { const value = data[key]; if (typeof value === "boolean") { return value ? 1 : 0; } return value; }); const result = db.prepare(sql).run(...values); return Number(result.lastInsertRowid); } export function updateCategoryItem( category: string, id: number, data: Record, ) { const db = getDatabase(); const normalized = category.trim().toLowerCase(); validateCategoryName(normalized); const payload = { ...data }; delete payload.id; const keys = Object.keys(payload); const assignments = keys.map((key) => `${key} = ?`).join(", "); const sql = `UPDATE ${normalized} SET ${assignments} WHERE id = ?`; const values = keys.map((key) => { const value = payload[key]; if (typeof value === "boolean") return value ? 1 : 0; return value; }); db.prepare(sql).run(...values, id); } export function getCategoryItem( category: string, id: number, ): Record | null { const db = getDatabase(); const normalized = category.trim().toLowerCase(); validateCategoryName(normalized); const row = db.prepare(`SELECT * FROM ${normalized} WHERE id = ?`).get(id); if (!row) return null; const result: Record = { ...row }; for (const [key, value] of Object.entries(result)) { if (typeof value === "number") { const template = BASE_COLUMNS.find((column) => column.name === key); if (!template) { // Heuristic: columns stored as BOOLEAN use 0/1 // If column type is INTEGER but values only 0/1, mantain numeric to allow consumer decide. } } } return result; }