332 lines
8.8 KiB
TypeScript
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;
|
|
}
|