291 lines
8.7 KiB
TypeScript
291 lines
8.7 KiB
TypeScript
import path from "node:path";
|
|
import { enToPtCategoryMap } from "./item-images";
|
|
|
|
export interface SearchParams {
|
|
q?: string;
|
|
categories?: string[];
|
|
rarities?: string[];
|
|
sort?:
|
|
| "name"
|
|
| "level-asc"
|
|
| "level-desc"
|
|
| "weight-asc"
|
|
| "weight-desc"
|
|
| "category";
|
|
vocation?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}
|
|
|
|
export interface SearchResult<T> {
|
|
items: T[];
|
|
total: number;
|
|
}
|
|
|
|
let schemaLogged = false;
|
|
|
|
function getDatabase() {
|
|
// Carrega better-sqlite3 apenas em runtime para não quebrar o build se não instalado
|
|
let Database: any;
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
Database = require("better-sqlite3");
|
|
} catch (_err) {
|
|
// Retorna null para permitir fallback silencioso (API responde vazio ao invés de 500)
|
|
return null as any;
|
|
}
|
|
|
|
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
|
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
return db;
|
|
}
|
|
|
|
// Tenta deduzir ordenação segura a partir do parâmetro de sort
|
|
function getOrderBy(
|
|
sort: SearchParams["sort"],
|
|
_vocation?: string,
|
|
): { sql: string; bindings: any[] } {
|
|
// Ordenação sem prioridade de vocação (filtro de voc estrito cuida apenas do WHERE)
|
|
switch (sort) {
|
|
case "level-asc":
|
|
return { sql: "level ASC, name ASC", bindings: [] };
|
|
case "level-desc":
|
|
return { sql: "level DESC, name ASC", bindings: [] };
|
|
case "weight-asc":
|
|
return { sql: "weight ASC, name ASC", bindings: [] };
|
|
case "weight-desc":
|
|
return { sql: "weight DESC, name ASC", bindings: [] };
|
|
case "category":
|
|
return { sql: "category ASC, name ASC", bindings: [] };
|
|
default:
|
|
return { sql: "name ASC", bindings: [] };
|
|
}
|
|
}
|
|
|
|
// Nota: O schema exato do tibiawiki.db pode variar. Partimos do pressuposto mínimo:
|
|
// Tabela 'items' com colunas: id, name, category, image, rarity, npc_price
|
|
// Ajustes poderão ser feitos após validação do schema real.
|
|
export function searchItems<T = any>(params: SearchParams): SearchResult<T> {
|
|
const {
|
|
q = "",
|
|
categories = [],
|
|
rarities = [],
|
|
sort = "name",
|
|
vocation,
|
|
page = 1,
|
|
pageSize = 24,
|
|
} = params;
|
|
|
|
const safePage = Math.max(1, page);
|
|
const safePageSize = Math.min(Math.max(1, pageSize), 50);
|
|
const offset = (safePage - 1) * safePageSize;
|
|
|
|
const db = getDatabase();
|
|
if (!db) {
|
|
// Fallback quando better-sqlite3 não está disponível no ambiente
|
|
return { items: [], total: 0 } as SearchResult<T>;
|
|
}
|
|
try {
|
|
// Schema logging desabilitado para reduzir poluição do console
|
|
if (!schemaLogged) {
|
|
schemaLogged = true;
|
|
}
|
|
|
|
const where: string[] = [];
|
|
const bindings: any[] = [];
|
|
|
|
if (q) {
|
|
where.push("LOWER(name) LIKE ?");
|
|
bindings.push(`%${q.toLowerCase()}%`);
|
|
}
|
|
if (categories.length > 0) {
|
|
const placeholders = categories.map(() => "?").join(",");
|
|
where.push(`LOWER(category) IN (${placeholders})`);
|
|
bindings.push(...categories.map((c) => c.toLowerCase()));
|
|
}
|
|
if (rarities.length > 0) {
|
|
const placeholders = rarities.map(() => "?").join(",");
|
|
where.push(`LOWER(rarity) IN (${placeholders})`);
|
|
bindings.push(...rarities.map((r) => r.toLowerCase()));
|
|
}
|
|
|
|
const { sql: orderBy, bindings: orderBindings } = getOrderBy(
|
|
sort,
|
|
vocation,
|
|
);
|
|
|
|
// Seleciona de múltiplas tabelas (PT) unificando shape padronizado
|
|
const allTables = db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence'",
|
|
)
|
|
.all() as { name: string }[];
|
|
// Filtra por categorias solicitadas (EN → PT); caso vazio, usa todas
|
|
const allowedPt = new Set(
|
|
categories.length > 0
|
|
? categories.map((en) => enToPtCategoryMap[en] || en)
|
|
: allTables.map((t) => t.name),
|
|
);
|
|
const candidateTables = allTables
|
|
.map((t) => t.name)
|
|
.filter((name) => allowedPt.has(name));
|
|
|
|
const selects: string[] = [];
|
|
const unionBindings: any[] = [];
|
|
for (const t of candidateTables) {
|
|
try {
|
|
const cols = db.prepare(`PRAGMA table_info(${t})`).all() as any[];
|
|
const colNames = new Set(
|
|
cols.map((c) => (c?.name || "").toLowerCase()),
|
|
);
|
|
// Requer colunas mínimas
|
|
if (
|
|
!colNames.has("id") ||
|
|
!colNames.has("nome") ||
|
|
!colNames.has("image_url")
|
|
)
|
|
continue;
|
|
// Se filtro de vocação foi solicitado e a tabela não possui coluna 'voc', ignora esta tabela
|
|
if (vocation && !colNames.has("voc")) {
|
|
continue;
|
|
}
|
|
|
|
const tableWhere: string[] = [];
|
|
if (q && colNames.has("nome")) {
|
|
tableWhere.push("LOWER(nome) LIKE ?");
|
|
unionBindings.push(`%${q.toLowerCase()}%`);
|
|
}
|
|
if (vocation && colNames.has("voc")) {
|
|
const sel = vocation.toLowerCase();
|
|
// Filtro estrito: comparar normalizando espaços
|
|
tableWhere.push("REPLACE(LOWER(voc), ' ', '') = REPLACE(?, ' ', '')");
|
|
unionBindings.push(sel);
|
|
}
|
|
const whereSqlTbl = tableWhere.length
|
|
? `WHERE ${tableWhere.join(" AND ")}`
|
|
: "";
|
|
// category: derivamos do nome da tabela (pt)
|
|
const levelCol =
|
|
colNames.has("nivel") || colNames.has("lvl")
|
|
? `COALESCE(${colNames.has("nivel") ? "nivel" : "NULL"}, ${colNames.has("lvl") ? "lvl" : "NULL"})`
|
|
: "NULL";
|
|
const weightCol =
|
|
colNames.has("peso") || colNames.has("peso_em_oz")
|
|
? `COALESCE(${colNames.has("peso") ? "peso" : "NULL"}, ${colNames.has("peso_em_oz") ? "peso_em_oz" : "NULL"})`
|
|
: "NULL";
|
|
const slotsCol = colNames.has("slots") ? "slots" : "NULL";
|
|
const skillCol = colNames.has("skill_para_usar")
|
|
? "skill_para_usar"
|
|
: "NULL";
|
|
// Campos extras opcionais por tabela (ex.: aljavas)
|
|
const extraCols = [
|
|
"lvl",
|
|
"voc",
|
|
"volume",
|
|
"bonus_de_skill",
|
|
"protecao",
|
|
"dropa_de",
|
|
] as const;
|
|
const extraSelect = extraCols
|
|
.map((c) => (colNames.has(c) ? `${c}` : `NULL AS ${c}`))
|
|
.join(",\n ");
|
|
selects.push(
|
|
`SELECT
|
|
id,
|
|
nome AS name,
|
|
'${t}' AS category,
|
|
image_url AS image,
|
|
${levelCol} AS level,
|
|
${weightCol} AS weight,
|
|
${slotsCol} AS slots,
|
|
${skillCol} AS skill${extraSelect ? `,\n ${extraSelect}` : ""}
|
|
FROM ${t} ${whereSqlTbl}`,
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[items-db] Ignorando tabela ${t}:`, (e as any)?.message);
|
|
}
|
|
}
|
|
|
|
if (selects.length === 0) {
|
|
console.warn("[items-db] Nenhuma tabela compatível encontrada");
|
|
return { items: [], total: 0 } as SearchResult<T>;
|
|
}
|
|
|
|
const unionSql = selects.join(" UNION ALL ");
|
|
// Contagem total
|
|
const totalStmt = db.prepare(
|
|
`SELECT COUNT(1) as cnt FROM (${unionSql}) AS u`,
|
|
);
|
|
const totalRow = totalStmt.get(...unionBindings) as { cnt: number };
|
|
const total = totalRow?.cnt ?? 0;
|
|
|
|
const selectStmt = db.prepare(
|
|
`SELECT * FROM (${unionSql}) AS u ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
|
);
|
|
const items = selectStmt.all(...unionBindings, safePageSize, offset) as T[];
|
|
|
|
return { items, total };
|
|
} finally {
|
|
try {
|
|
db.close();
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Busca um item por ID no banco de dados
|
|
* Busca em todas as tabelas de categorias até encontrar o item
|
|
*/
|
|
export function getItemById(itemId: number, itemName?: string): any | null {
|
|
const db = getDatabase();
|
|
if (!db) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Buscar em todas as tabelas de categorias
|
|
const tables = db
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
|
)
|
|
.all() as { name: string }[];
|
|
|
|
for (const table of tables) {
|
|
try {
|
|
let item: any = null;
|
|
|
|
// Se itemName foi fornecido, buscar por ID E NOME (case-insensitive)
|
|
if (itemName) {
|
|
item = db
|
|
.prepare(
|
|
`SELECT * FROM ${table.name} WHERE id = ? AND LOWER(nome) = LOWER(?)`,
|
|
)
|
|
.get(itemId, itemName);
|
|
} else {
|
|
// Se não há nome, buscar apenas por ID (comportamento antigo)
|
|
item = db
|
|
.prepare(`SELECT * FROM ${table.name} WHERE id = ?`)
|
|
.get(itemId);
|
|
}
|
|
|
|
if (item) {
|
|
return item;
|
|
}
|
|
} catch (_err) {}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error("[items-db] Erro ao buscar item por ID:", error);
|
|
return null;
|
|
} finally {
|
|
try {
|
|
db.close();
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
}
|