Files
pagodera/lib/items/sqlite-manager.ts
T
2025-11-11 03:08:36 -03:00

332 lines
8.8 KiB
TypeScript

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<string, unknown>;
}
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<string, unknown>,
) {
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<string, unknown> | 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<string, unknown> = { ...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;
}