feat: add organizeSql option for organized catalog_pages with English captions
This commit is contained in:
1 parent
2ee5ba5c4c
commit
04b84e6055
2 files changed
+320
-6
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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<CatalogSqlResult> {
|
||||
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<string, number>();
|
||||
|
||||
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++;
|
||||
|
||||
Reference in new issue
Block a user