import { existsSync } from "node:fs"; import path from "node:path"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { fetchSourceFurnidata } from "@/lib/services/clone-import"; import { listSources } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { ensureDirectories } from "./furni-import"; export interface AuditEvent { type: | "progress" | "items_loaded" | "catalog_loaded" | "checking" | "batch_complete" | "error"; message?: string; current?: number; total?: number; section?: string; issues?: AuditIssue[]; missingFromSources?: MissingFromSource[]; summary?: AuditSummary; } export interface AuditIssue { type: string; severity: "error" | "warning" | "info"; classname?: string; itemId?: number; catalogId?: number; message: string; sourceName?: string; } export interface MissingFromSource { sourceName: string; sourceId: string; classname: string; itemName: string; } export interface AuditSummary { totalItemsBase: number; totalCatalogItems: number; missingCatalog: number; orphanedCatalog: number; missingNitro: number; missingIcon: number; duplicates: number; missingFromSourcesTotal: number; } export async function runCatalogAudit( onEvent?: (evt: AuditEvent) => void, ): Promise { const issues: AuditIssue[] = []; const missingFromSources: MissingFromSource[] = []; try { await ensureDirectories(); } catch (err) { onEvent?.({ type: "error", message: `ensureDirectories failed: ${(err as Error).message}`, }); return; } let iconDir: string; let nitroDir: string; try { const dirs = await getFurniAssetDirs(); iconDir = dirs.iconDir; nitroDir = dirs.nitroDir; } catch (err) { onEvent?.({ type: "error", message: `getFurniAssetDirs failed: ${(err as Error).message}`, }); return; } onEvent?.({ type: "progress", message: "Loading items_base…" }); let items: Array<{ id: number; item_name: string; public_name: string; type: string; }>; try { const [rows] = (await db.execute(sql` SELECT id, item_name, public_name, type FROM items_base ORDER BY id `)) as unknown as [ Array<{ id: number; item_name: string; public_name: string; type: string; }>, unknown, ]; items = rows; } catch (err) { onEvent?.({ type: "error", message: `items_base query failed: ${(err as Error).message}`, }); return; } onEvent?.({ type: "items_loaded", total: items.length }); onEvent?.({ type: "progress", message: "Loading catalog_items…" }); let catalogItems: Array<{ id: number; item_ids: string; catalog_name: string; }>; try { const [rows] = (await db.execute(sql` SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id `)) as unknown as [ Array<{ id: number; item_ids: string; catalog_name: string }>, unknown, ]; catalogItems = rows; } catch (err) { onEvent?.({ type: "error", message: `catalog_items query failed: ${(err as Error).message}`, }); return; } onEvent?.({ type: "catalog_loaded", total: catalogItems.length }); const itemIdsWithCatalog = new Set(); for (const ci of catalogItems) { const ids = ci.item_ids.split(";").map(Number).filter(Boolean); for (const id of ids) { itemIdsWithCatalog.add(id); } } const itemById = new Map(); const itemByName = new Map(); const classnameCount = new Map(); for (const item of items) { itemById.set(item.id, item); itemByName.set(item.item_name, item); classnameCount.set( item.item_name, (classnameCount.get(item.item_name) ?? 0) + 1, ); } onEvent?.({ type: "checking", section: "missing_catalog", total: items.length, }); for (let i = 0; i < items.length; i++) { const item = items[i]; if (!itemIdsWithCatalog.has(item.id)) { issues.push({ type: "missing_catalog", severity: "warning", classname: item.item_name, itemId: item.id, message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`, }); } if (i % 100 === 0) { onEvent?.({ type: "checking", section: "missing_catalog", current: i, total: items.length, }); } } onEvent?.({ type: "checking", section: "duplicates" }); for (const [name, count] of classnameCount) { if (count > 1) { issues.push({ type: "duplicate_classname", severity: "error", classname: name, message: `"${name}" appears ${count} times in items_base`, }); } } onEvent?.({ type: "checking", section: "orphaned_catalog", total: catalogItems.length, }); for (let i = 0; i < catalogItems.length; i++) { const ci = catalogItems[i]; const ids = ci.item_ids.split(";").map(Number).filter(Boolean); for (const id of ids) { if (!itemById.has(id)) { issues.push({ type: "orphaned_catalog", severity: "error", catalogId: ci.id, message: `catalog_items ID ${ci.id} ("${ci.catalog_name}") references items_base ID ${id} which does not exist`, }); } } if (i % 200 === 0) { onEvent?.({ type: "checking", section: "orphaned_catalog", current: i, total: catalogItems.length, }); } } onEvent?.({ type: "checking", section: "missing_nitro", total: items.length, }); for (let i = 0; i < items.length; i++) { const item = items[i]; const nitroPath = path.join(nitroDir, `${item.item_name}.nitro`); if (!existsSync(nitroPath)) { issues.push({ type: "missing_nitro", severity: "error", classname: item.item_name, itemId: item.id, message: `"${item.item_name}" (ID ${item.id}) has no .nitro file`, }); } if (i % 100 === 0) { onEvent?.({ type: "checking", section: "missing_nitro", current: i, total: items.length, }); } } onEvent?.({ type: "checking", section: "missing_icon", total: items.length }); for (let i = 0; i < items.length; i++) { const item = items[i]; const iconPath = path.join(iconDir, `${item.item_name}_icon.png`); if (!existsSync(iconPath)) { issues.push({ type: "missing_icon", severity: "warning", classname: item.item_name, itemId: item.id, message: `"${item.item_name}" (ID ${item.id}) has no icon file`, }); } if (i % 100 === 0) { onEvent?.({ type: "checking", section: "missing_icon", current: i, total: items.length, }); } } onEvent?.({ type: "progress", message: "Comparing with clone sources…" }); const sources = await listSources(); for (const source of sources) { try { const entries = await fetchSourceFurnidata(source.furnidataUrl); for (const entry of entries) { if (!itemByName.has(entry.classname)) { missingFromSources.push({ sourceName: source.name, sourceId: source.id, classname: entry.classname, itemName: entry.name, }); } } } catch { issues.push({ type: "source_fetch_failed", severity: "error", sourceName: source.name, message: `Failed to fetch furnidata from "${source.name}"`, }); } } issues.sort((a, b) => { const sev = { error: 0, warning: 1, info: 2 }; return (sev[a.severity] ?? 0) - (sev[b.severity] ?? 0); }); const summary: AuditSummary = { totalItemsBase: items.length, totalCatalogItems: catalogItems.length, missingCatalog: issues.filter((i) => i.type === "missing_catalog").length, orphanedCatalog: issues.filter((i) => i.type === "orphaned_catalog").length, missingNitro: issues.filter((i) => i.type === "missing_nitro").length, missingIcon: issues.filter((i) => i.type === "missing_icon").length, duplicates: issues.filter((i) => i.type === "duplicate_classname").length, missingFromSourcesTotal: missingFromSources.length, }; onEvent?.({ type: "batch_complete", issues, missingFromSources, summary, }); }