import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { readFurniData, withFurniDataLock, writeFurniData, } from "@/lib/services/furni-data"; import { autoPriceFurni, getOrCreateCategoryPage, } from "@/lib/services/furni-import"; export interface MissingCatalogEntry { classname: string; itemId: number; itemType: string; } export interface CatalogSqlResult { statements: string[]; sql: string; count: number; } function escSqlLiteral(value: string): string { return value.replace(/'/g, "''"); } /** * Build standalone `catalog_items` INSERT statements for items_base entries * that exist (with assets) but have no catalog entry yet. Ids and prices are * resolved the same way the app's own imports do it (autoPriceFurni + category * page + next catalog id), so running the SQL matches an app-managed import. */ export async function generateCatalogSql( entries: MissingCatalogEntry[], ): Promise { const statements: string[] = [ "-- AtomCMS catalog repair", `-- Generated ${new Date().toISOString()}`, `-- ${entries.length} item(s) to add`, "START TRANSACTION;", ]; // The generation pass does not INSERT, so MAX(id) would never advance and // per-entry allocateCatalogItemId would hand out the same id every time. // Read the current max once and hand out sequential ids from there. const [maxIdResult] = (await db.execute(sql` SELECT MAX(id) as maxId FROM catalog_items `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; let generated = 0; for (const entry of entries) { let pageId: number; try { pageId = await getOrCreateCategoryPage(entry.classname, entry.itemType); } catch { continue; } const price = autoPriceFurni(entry.classname); statements.push( `INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '');`, ); nextId++; generated++; } statements.push("COMMIT;"); return { statements, sql: statements.join("\n"), count: generated }; } /** Execute generated INSERT statements directly against the database. */ export async function applyCatalogSql( statements: string[], ): Promise<{ applied: number; failed: number }> { let applied = 0; let failed = 0; for (const statement of statements) { const trimmed = statement.trim(); if ( !trimmed || trimmed.startsWith("--") || trimmed === "START TRANSACTION;" || trimmed === "COMMIT;" ) { continue; } try { await db.execute(sql.raw(trimmed)); applied++; } catch { failed++; } } return { applied, failed }; } export interface FurniDataRepairResult { added: number; removedDuplicates: number; } function buildMinimalFurniEntry(item: { id: number; item_name: string; public_name: string; }): Record { return { id: item.id, classname: item.item_name, revision: 0, category: "unknown", defaultdir: 0, xdim: 1, ydim: 1, partcolors: { color: [] }, name: item.public_name || item.item_name, description: "", adurl: "", offerid: item.id, buyout: false, rentofferid: -1, rentbuyout: false, bc: false, excludeddynamic: false, customparams: "", specialtype: 0, canstandon: false, cansiton: false, canlayon: false, furniline: "", environment: "", rare: false, }; } /** * Add FurnitureData.json entries for items_base rows that are missing from the * file, and remove duplicate classnames (keeping the first occurrence). */ export async function repairFurniData( items: Array<{ id: number; item_name: string; public_name: string; type: string; }>, ): Promise { if (items.length === 0) return { added: 0, removedDuplicates: 0 }; return withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record< string, { furnitype: Array> } >; // 1) Deduplicate classnames per section (keep the first occurrence). let removedDuplicates = 0; for (const section of ["roomitemtypes", "wallitemtypes"] as const) { const sec = furniData[section]; if (!sec?.furnitype) continue; const seen = new Set(); const deduped = sec.furnitype.filter((e) => { const classname = typeof e.classname === "string" ? e.classname : ""; if (!classname) return true; if (seen.has(classname)) { removedDuplicates++; return false; } seen.add(classname); return true; }); if (deduped.length !== sec.furnitype.length) { furniData[section].furnitype = deduped; } } // 2) Add entries for items_base rows missing from the file. const existing = new Set(); for (const section of ["roomitemtypes", "wallitemtypes"] as const) { for (const e of furniData[section]?.furnitype ?? []) { if (typeof e.classname === "string") existing.add(e.classname); } } const added: Array<{ entry: Record; itemType: string }> = []; for (const item of items) { if (existing.has(item.item_name)) continue; added.push({ entry: buildMinimalFurniEntry(item), itemType: item.type, }); } // 3) Write once if anything changed. if (removedDuplicates > 0 || added.length > 0) { for (const { entry, itemType } of added) { const sectionKey = itemType === "i" ? "wallitemtypes" : "roomitemtypes"; if (!furniData[sectionKey]) { furniData[sectionKey] = { furnitype: [] }; } furniData[sectionKey].furnitype.push(entry); } await writeFurniData(furniData as Record); } return { added: added.length, removedDuplicates }; }); }