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 { 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(params: SearchParams): SearchResult { 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; } 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; } 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 } } }