From 04b84e6055ce87967f2139b0f402a3efcfb3ad54 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 4 Aug 2026 16:05:11 +0200 Subject: [PATCH] feat: add organizeSql option for organized catalog_pages with English captions --- src/lib/services/catalog-audit.ts | 6 +- src/lib/services/catalog-repair.ts | 320 ++++++++++++++++++++++++++++- 2 files changed, 320 insertions(+), 6 deletions(-) diff --git a/src/lib/services/catalog-audit.ts b/src/lib/services/catalog-audit.ts index 07adcdc4..08bf8280 100644 --- a/src/lib/services/catalog-audit.ts +++ b/src/lib/services/catalog-audit.ts @@ -125,6 +125,7 @@ export interface CatalogAuditOptions { repairNitros?: boolean; sql?: boolean; applySql?: boolean; + organizeSql?: boolean; repairFurniData?: boolean; repairStructure?: boolean; } @@ -316,6 +317,7 @@ export async function runCatalogAudit( classname: item.item_name, itemId: item.id, itemType: item.type, + publicName: item.public_name, }); } } @@ -652,7 +654,9 @@ export async function runCatalogAudit( message: `Generating catalog SQL for ${missingCatalogEntries.length} items…`, }); try { - const result = await generateCatalogSql(missingCatalogEntries); + const result = await generateCatalogSql(missingCatalogEntries, { + organizeSql: options?.organizeSql, + }); catalogSqlCount = result.count; onEvent?.({ type: "sql_ready", diff --git a/src/lib/services/catalog-repair.ts b/src/lib/services/catalog-repair.ts index ccca31e3..69d39210 100644 --- a/src/lib/services/catalog-repair.ts +++ b/src/lib/services/catalog-repair.ts @@ -7,13 +7,17 @@ import { } 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 { @@ -26,14 +30,273 @@ function escSqlLiteral(value: string): string { return value.replace(/'/g, "''"); } +/** + * 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", + + // Clothing / Fashion + clothing: "Clothing", + clothes: "Clothing", + shirt: "Clothing", + pants: "Clothing", + shoes: "Clothing", + hat: "Clothing", + costume: "Costumes", + dress: "Dresses", + + // 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", + + // VIP / Rares / Nitro + rare: "Rares", + vip: "VIP", + exclusive: "Exclusive", + collectible: "Collectible", + vintage: "Vintage", + retro: "Retro", + classic: "Classic", +}; + +/** + * 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! + 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", @@ -42,6 +305,20 @@ export async function generateCatalogSql( "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. @@ -50,17 +327,50 @@ export async function generateCatalogSql( `)) 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; - try { - pageId = await getOrCreateCategoryPage(entry.classname, entry.itemType); - } catch { - continue; + 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); + const displayName = entry.publicName || 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', '');`, + `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(displayName)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '');`, ); nextId++; generated++;