import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { readFurniData, withFurniDataLock, writeFurniData, } from "@/lib/services/furni-data"; import { autoPriceFurni, CATEGORY_PAGE, classifyFurni, getOrCreateCategoryPage, getOrCreateImportedParentPage, } from "@/lib/services/furni-import"; export interface MissingCatalogEntry { classname: string; itemId: number; itemType: string; publicName?: string; } export interface CatalogSqlResult { statements: string[]; sql: string; count: number; } function escSqlLiteral(value: string): string { return value.replace(/'/g, "''"); } const VALID_IDENTIFIER = /^[a-zA-Z0-9_]+$/; function quoteIdentifier(identifier: string): string { if (!VALID_IDENTIFIER.test(identifier)) throw new Error("Invalid identifier"); return `\`${identifier}\``; } /** * Mapping of event prefix substrings (lowercase, no leading underscore) to * their human-readable English page labels. Covers every seasonal/holiday/event * abbreviation that has appeared across Habbo's history. Extend this map as new * prefixes surface. */ const EVENT_PREFIXES: Record = { // Seasonal / Holiday events xmas: "Christmas", christmas: "Christmas", xmastree: "Christmas", xmasgift: "Christmas", xmaspresent: "Christmas", xmaseve: "Christmas", hween: "Halloween", halloween: "Halloween", hw: "Halloween", hall: "Halloween", spook: "Halloween", easter: "Easter", easterbunny: "Easter", val: "Valentine's Day", valentine: "Valentine's Day", valentines: "Valentine's Day", love: "Valentine's Day", heart: "Valentine's Day", newyear: "New Year", nye: "New Year", countdown: "New Year", summer: "Summer", summersale: "Summer", sun: "Summer", tropic: "Summer", tropical: "Summer", resort: "Summer", fall: "Fall", autumn: "Autumn", harvest: "Fall", winter: "Winter", snow: "Winter", snowman: "Winter", frost: "Winter", glacier: "Winter", arctic: "Winter", icicle: "Winter", carnival: "Carnival", festival: "Carnival", mardi: "Carnival", mardigras: "Carnival", fair: "Fair", rodeo: "Fair", thanksgiving: "Thanksgiving", stpat: "St. Patrick's Day", stpaddy: "St. Patrick's Day", green: "St. Patrick's Day", hanukkah: "Hanukkah", chanukah: "Hanukkah", diwali: "Diwali", deepavali: "Diwali", holiday: "Holiday", holidays: "Holiday", // Habbo-specific events anniversary: "Anniversary", anniv: "Anniversary", hb: "Habbo Birthday", hbday: "Habbo Birthday", birthday: "Birthday", habbohome: "HabboHome", quest: "Quest Event", mission: "Mission Event", task: "Task Event", challenge: "Challenge Event", gm: "GM Event", gamemaker: "GM Event", moderation: "Staff Event", staff: "Staff Event", club: "Habbo Club", hc: "Habbo Club", nitro: "Nitro Event", bingo: "Bingo", quiz: "Quiz", trivia: "Quiz", game: "Games", arcade: "Arcade", puzzle: "Puzzles", // Retro hotel / community server prefixes leet: "Leet", hubbly: "Hubbly", crazy: "Crazy", shock: "Shock", flash: "Flash", nova: "Nova", uber: "Uber", vip: "VIP", exclusive: "Exclusive", collectible: "Collectible", vintage: "Vintage", retro: "Retro", classic: "Classic", rare: "Rares", // Furniture categories room: "Room Furniture", wall: "Wall Items", floor: "Floor Items", wallpaper: "Wallpapers", tile: "Floor Tiles", carpet: "Carpets", rug: "Rugs", door: "Doors", gate: "Gates", stair: "Stairs", light: "Lighting", lamp: "Lighting", chair: "Chairs", table: "Tables", sofa: "Sofas", bed: "Beds", storage: "Storage", wardrobe: "Wardrobes", closet: "Wardrobes", plant: "Plants", flower: "Plants", tree: "Plants", bush: "Plants", grass: "Plants", sign: "Signs", board: "Signs", poster: "Posters", banner: "Banners", flag: "Flags", statue: "Statues", fountain: "Fountains", pond: "Ponds", garden: "Garden", kitchen: "Kitchen", bathroom: "Bathroom", bath: "Bathroom", book: "Books", shelf: "Shelves", bookshelf: "Bookshelves", cabinet: "Cabinets", drawer: "Drawers", chest: "Storage", box: "Storage", trunk: "Storage", crate: "Storage", // Pets pet: "Pets", dog: "Dogs", cat: "Cats", bird: "Birds", horse: "Horses", pig: "Pets", monkey: "Monkeys", frog: "Pets", bear: "Bears", spider: "Pets", bunny: "Rabbits", rabbit: "Rabbits", reptile: "Reptiles", lizard: "Reptiles", snake: "Reptiles", tank: "Fish Tank", fish: "Fish", // Effects / Badges badge: "Badges", effect: "Effects", bg: "Background", aura: "Auras", // Wired wired: "Wired", wf: "Wired", trigger: "Wired", // Credits / Exchange credit: "Credits", cf: "Credit Furniture", exe: "Exchange", diamond: "Diamonds", ducket: "Duckets", // Music trax: "Music", jukebox: "Music", sound: "Sound", record: "Records", stereo: "Music", musicdisk: "Music", musicdisc: "Music", // Clothing / Fashion clothing: "Clothing", clothes: "Clothing", shirt: "Shirt", pants: "Pants", shoes: "Shoes", hat: "Hat", costume: "Costumes", dress: "Dresses", }; /** * Derive a human-readable English page caption from a furniture classname. * Automatically detects event prefixes (xmas2025 → "Christmas 2025") using a * comprehensive dictionary of Habbo event abbreviations. Handles multiple * separator styles (_, nothing). When no known pattern matches, returns the * fallback caption. */ function derivePageCaption(classname: string, fallback: string): string { const lower = classname.toLowerCase(); // Try to extract a year from anywhere in the classname. const yearMatch = lower.match(/(\d{4})/); const year = yearMatch ? yearMatch[1] : null; // Check for known event prefixes. Iterate from longest prefix to shortest // to avoid partial matches (e.g., "hween" before "hw"). const sortedPrefixes = Object.keys(EVENT_PREFIXES).sort( (a, b) => b.length - a.length, ); for (const prefix of sortedPrefixes) { const re = new RegExp(`^${prefix.replace(/_/g, "[_]?")}(?:_)?(\\d*)`, "i"); const m = lower.match(re); if (m) { const matchEnd = (m.index ?? 0) + m[0].length; const nextChar = lower[matchEnd]; if ( !nextChar || nextChar === "_" || nextChar === "." || /\d/.test(nextChar) ) { const event = EVENT_PREFIXES[prefix.toLowerCase()]; return year ? `${event} ${year}` : event; } } } // Also try matching suffix patterns (e.g., "something_xmas2025"). for (const prefix of sortedPrefixes) { const re = new RegExp( `${prefix.replace(/_/g, "[_]?")}(?:_)?(\\d{4})(_|$)`, "i", ); const m = lower.match(re); if (m) { const event = EVENT_PREFIXES[prefix.toLowerCase()]; return year ? `${event} ${year}` : event; } } return fallback; } /** * 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. * * When `organizeSql` is true, the output also contains `catalog_pages` INSERT * statements that organise items into sub-pages under "Imported Furniture", * using derived English captions by event+year (e.g. "Christmas 2025"). */ export async function generateCatalogSql( entries: MissingCatalogEntry[], opts: { organizeSql?: boolean } = {}, ): Promise { const statements: string[] = [ "-- AtomCMS catalog repair", `-- Generated ${new Date().toISOString()}`, `-- ${entries.length} item(s) to add`, "START TRANSACTION;", ]; const { organizeSql = false } = opts; // When organizing, we also emit catalog_pages INSERTs for new sub-pages. // Read the Imported Furniture parent page id and current max page id once. let parentPageId: number | null = null; let nextPageId = 0; if (organizeSql) { parentPageId = await getOrCreateImportedParentPage(); const [maxPageResult] = (await db.execute(sql` SELECT MAX(id) as maxId FROM catalog_pages `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; nextPageId = Number(maxPageResult[0]?.maxId ?? 0) + 1; } // 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; // Build a map of caption_save → pageId so multiple items in the same // category share one page (and we emit the page INSERT only once). const pageMap = new Map(); let generated = 0; for (const entry of entries) { let pageId: number; if (organizeSql && parentPageId) { const catKey = classifyFurni(entry.classname, entry.itemType); const captionSave = `imp_${catKey}`; const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; const caption = derivePageCaption( entry.classname, entry.publicName ?? catInfo.label, ); let existing = pageMap.get(captionSave); if (!existing) { const [found] = (await db.execute(sql` SELECT id FROM catalog_pages WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId} `)) as unknown as [Array<{ id: number }>, unknown]; if (found?.[0]?.id) { pageMap.set(captionSave, found[0].id); } else { const newId = nextPageId++; pageMap.set(captionSave, newId); statements.push( `INSERT INTO catalog_pages (id, caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${newId}, '${escSqlLiteral(captionSave)}', '${escSqlLiteral(caption)}', 'default_3x3', ${parentPageId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '');`, ); } existing = pageMap.get(captionSave); } if (!existing) continue; pageId = existing; } else { try { pageId = await getOrCreateCategoryPage(entry.classname, entry.itemType); } catch { continue; } } const price = autoPriceFurni(entry.classname); // catalog_name must match items_base.item_name (the classname), exactly // like the app's own import pipeline, so the studio's in-catalog joins // and the emulator's item lookup stay consistent. offer_id is the item id // (sprite id), not a literal '-1'. 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, ${entry.itemId}, '');`, ); 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 OrphanedCatalogResult { removed: number; cleaned: number; } export interface DuplicateClassnameResult { merged: number; rowsRemoved: number; remapped: number; } /** * Remove catalog_items rows whose item_ids only reference non-existent * items_base entries, and strip orphaned ids from mixed rows. Also deletes * catalog_items_limited rows that pointed at the removed catalog entries so no * new orphans are left behind. */ export async function repairOrphanedCatalog(): Promise { const [itemRows] = (await db.execute(sql` SELECT id FROM items_base `)) as unknown as [Array<{ id: number }>, unknown]; const valid = new Set(); for (const row of itemRows) valid.add(Number(row.id)); const [catalogRows] = (await db.execute(sql` SELECT id, item_ids FROM catalog_items `)) as unknown as [Array<{ id: number; item_ids: string }>, unknown]; let removed = 0; let cleaned = 0; for (const row of catalogRows) { const ids = (row.item_ids ?? "") .split(";") .map((s) => Number(s.trim())) .filter(Boolean); if (ids.length === 0) continue; const kept = ids.filter((id) => valid.has(id)); if (kept.length === ids.length) continue; if (kept.length === 0) { await db.execute(sql` DELETE FROM catalog_items_limited WHERE catalog_item_id = ${row.id} `); await db.execute(sql`DELETE FROM catalog_items WHERE id = ${row.id}`); removed++; } else { await db.execute(sql` UPDATE catalog_items SET item_ids = ${kept.join(";")} WHERE id = ${row.id} `); cleaned++; } } return { removed, cleaned }; } /** * Merge duplicate classnames in items_base into a single canonical row per * classname, remapping every reference (catalog item id lists, player items, * presents, marketplace, etc.) to the canonical id, then delete the duplicate * rows. The canonical id is the one players actually purchase through * catalog_items when present, otherwise the lowest id. */ export async function repairDuplicateClassnames(): Promise { const [itemRows] = (await db.execute(sql` SELECT id, item_name FROM items_base `)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; const byName = new Map(); for (const row of itemRows) { const arr = byName.get(row.item_name) ?? []; arr.push(Number(row.id)); byName.set(row.item_name, arr); } const [catalogRows] = (await db.execute(sql` SELECT item_ids FROM catalog_items `)) as unknown as [Array<{ item_ids: string }>, unknown]; const referenced = new Set(); for (const row of catalogRows) { for (const part of (row.item_ids ?? "").split(";")) { const id = Number(part.trim()); if (id) referenced.add(id); } } const remap = new Map(); let merged = 0; for (const ids of byName.values()) { if (ids.length <= 1) continue; merged++; const sorted = [...ids].sort((a, b) => a - b); const canonical = sorted.find((id) => referenced.has(id)) ?? sorted[0]; for (const id of ids) { if (id !== canonical) remap.set(id, canonical); } } if (remap.size === 0) return { merged: 0, rowsRemoved: 0, remapped: 0 }; let remapped = 0; // Semicolon-separated item_ids columns. const listTables: Array<{ table: string; idCol: string }> = [ { table: "catalog_items", idCol: "id" }, { table: "catalog_items_bc", idCol: "id" }, { table: "logs_shop_purchases", idCol: "id" }, ]; for (const { table, idCol } of listTables) { const [rows] = (await db.execute( sql`SELECT ${sql.raw(quoteIdentifier(idCol))} AS pk, item_ids AS v FROM ${sql.raw(quoteIdentifier(table))}`, )) as unknown as [Array<{ pk: number; v: string | null }>, unknown]; for (const row of rows) { if (!row.v) continue; const parts = row.v .split(";") .map((s) => s.trim()) .filter(Boolean); let changed = false; const out: number[] = []; const seen = new Set(); for (const part of parts) { const id = Number(part); const mapped = remap.get(id) ?? id; if (mapped !== id) changed = true; if (seen.has(mapped)) continue; seen.add(mapped); out.push(mapped); } if (!changed) continue; await db.execute( sql`UPDATE ${sql.raw(quoteIdentifier(table))} SET item_ids = ${out.join(";")} WHERE ${sql.raw(quoteIdentifier(idCol))} = ${row.pk}`, ); remapped++; } } // Single item_id columns that reference items_base.id. Remap in chunks via // a single CASE statement per table to keep the query count low even with // thousands of duplicate rows. const singleTables = [ "catalog_items_limited", "items", "items_presents", "pet_items", "pet_foods", "pet_drinks", "items_hoppers", "marketplace_items", "items_highscore_data", "items_crackable", "builders_club_items", "recycler_prizes", "gift_wrappers", "youtube_playlists", "room_trade_log_items", "calendar_rewards", "room_trax_playlist", "trax_playlist", "website_event_prizes", "room_templates_items", "crafting_recipes_ingredients", "website_rare_values", "logs_economy", ]; const entries = [...remap.entries()]; const CHUNK = 400; for (const table of singleTables) { for (let i = 0; i < entries.length; i += CHUNK) { const chunk = entries.slice(i, i + CHUNK); const cases = chunk.map( ([dup, canonical]) => sql`WHEN ${dup} THEN ${canonical}`, ); const dupList = chunk.map(([dup]) => dup); try { const [result] = (await db.execute(sql` UPDATE ${sql.raw(quoteIdentifier(table))} SET item_id = CASE item_id ${sql.join(cases, sql` `)} ELSE item_id END WHERE item_id IN (${sql.join(dupList, sql`, `)}) `)) as unknown as [Record, unknown]; remapped += Number(result.affectedRows ?? 0); } catch { // Table may not exist on some hotel schemas — skip it. } } } const dupIds = entries.map(([dup]) => dup); await db.execute(sql` DELETE FROM items_base WHERE id IN (${sql.join(dupIds, sql`, `)}) `); return { merged, rowsRemoved: dupIds.length, remapped }; } export interface FurniDataRepairResult { added: number; removedDuplicates: number; removedIdConflicts: 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, remove duplicate classnames, and resolve spriteId conflicts * (keeping the first classname per id, removing conflicting ones). */ 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, removedIdConflicts: 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) Resolve spriteId conflicts (same id used by different classnames) — keep first, remove others. let removedIdConflicts = 0; for (const section of ["roomitemtypes", "wallitemtypes"] as const) { const sec = furniData[section]; if (!sec?.furnitype) continue; const idToClassname = new Map(); const deduped = sec.furnitype.filter((e) => { const id = Number(e?.id); const classname = typeof e.classname === "string" ? e.classname : ""; if (!Number.isFinite(id) || id <= 0 || !classname) return true; const existing = idToClassname.get(id); if (existing && existing !== classname) { // Conflict: same id used by different classname — keep first, remove this removedIdConflicts++; return false; } if (!existing) { idToClassname.set(id, classname); } return true; }); if (deduped.length !== sec.furnitype.length) { furniData[section].furnitype = deduped; } } // 3) 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, }); } // 4) Write once if anything changed. if (removedDuplicates > 0 || removedIdConflicts > 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, removedIdConflicts }; }); }