import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { and, eq, type SQL, sql } from "drizzle-orm"; import { CatalogPages, db, ItemsBase } from "@/lib/db"; import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; import { appendFurniEntry, buildFurniEntry, removeFurniEntry, } from "@/lib/services/furni-data"; import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache"; import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; // Re-export the download helpers now owned by the shared import core. // Existing furni callers keep importing them from here. export { downloadFile, validatePngBytes, validateSwfBytes, } from "@/lib/services/import/core/download"; import { DEFAULT_FURNI_ICON_DIR, DEFAULT_FURNI_NITRO_DIR, DEFAULT_FURNI_SWF_DIR, getFurniAssetDirs, getFurniAssetWriteTargets, } from "@/lib/services/furni-asset-dirs"; import { downloadFile } from "@/lib/services/import/core/download"; import type { ConversionResult } from "@/lib/services/swf-to-nitro"; import { convertSwfToNitro, extractIconFromSwf, } from "@/lib/services/swf-to-nitro"; import type { ImportSingleResult } from "@/types/furni"; // ── Constants ──────────────────────────────────────────────────────── // turbopackIgnore hints: this module is reached by App Router routes; without // them Turbopack's NFT tracer marks the whole project as a dependency because // it can't prove the dynamic suffixes (file names) are bounded to the prefix. export const FURNI_ICON_DIR = DEFAULT_FURNI_ICON_DIR; export const FURNI_SWF_DIR = DEFAULT_FURNI_SWF_DIR; export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR; export const IMPORTED_PAGE_CAPTION_SAVE = "imported_furni"; export const IMPORTED_PAGE_CAPTION = "Imported Furniture"; // Catalog sub-page definitions (key = internal id used for caption_save `imp_`) export const CATEGORY_PAGE: Record< string, { label: string; icon: number; order: number } > = { credits: { label: "Credits & Exchange", icon: 263, order: 1 }, rares: { label: "Rares", icon: 11, order: 2 }, wired: { label: "Wired", icon: 1, order: 3 }, nft: { label: "NFT", icon: 12, order: 4 }, seasonal: { label: "Seasonal", icon: 10, order: 5 }, music: { label: "Music & Sound", icon: 13, order: 6 }, clothing: { label: "Clothing", icon: 3, order: 7 }, builders: { label: "Builders Club", icon: 7, order: 8 }, walls: { label: "Wall Items", icon: 5, order: 9 }, promo: { label: "Promo & Custom", icon: 2, order: 10 }, trophies: { label: "Trophies", icon: 6, order: 11 }, pets: { label: "Pets", icon: 4, order: 12 }, room: { label: "Room & Building", icon: 8, order: 13 }, other: { label: "Other", icon: 1, order: 99 }, }; // Classname prefix → category key (order matters: first match wins) export const CLASSNAME_RULES: Array<{ test: (cn: string, type: string) => boolean; cat: string; }> = [ { test: (cn) => /^CF_/i.test(cn) || /^exe_/i.test(cn) || cn === "credit", cat: "credits", }, { test: (cn) => /^rare_/i.test(cn), cat: "rares" }, { test: (cn) => /^wf_/i.test(cn), cat: "wired" }, { test: (cn) => /^nft_/i.test(cn), cat: "nft" }, { test: (cn) => /^(xmas|hween|easter|val_|valentine|hw_|newyear|summer|carnival)/i.test( cn, ), cat: "seasonal", }, { test: (cn) => /^(trax|jukebox|sound|music)/i.test(cn), cat: "music" }, { test: (cn) => /^(clothing|clothes)/i.test(cn), cat: "clothing" }, { test: (cn) => /^bc_/i.test(cn), cat: "builders" }, { test: (cn) => /^(poster|wall_|window_|wallpaper)/i.test(cn), cat: "walls" }, { test: (_cn, type) => type === "wallitem" || type === "i", cat: "walls" }, { test: (cn) => /^(ads_|cstm_)/i.test(cn), cat: "promo" }, { test: (cn) => /^(trophy|gold_|silver_|bronze_)/i.test(cn), cat: "trophies", }, { test: (cn) => /^(pet|dog|cat_|bird|horse|pig|monkey|frog|dragon|bear_|spider|bunny)/i.test( cn, ), cat: "pets", }, { test: (cn) => /^(room_|floor_|tile_|carpet|rug|door_|gate_|stair)/i.test(cn), cat: "room", }, ]; // ── Helpers ────────────────────────────────────────────────────────── export async function ensureDirectories(): Promise { const targets = await getFurniAssetWriteTargets(); const dirs = [ targets.swfDir, targets.iconDir, targets.nitroDir, ...targets.mirrorDirs.flatMap((d) => [d.swfDir, d.iconDir, d.nitroDir]), ]; await Promise.all( dirs .filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir)) .map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true }), ), ); } async function mirrorFileToDirs( sourcePath: string, fileName: string, dirs: string[], warnings?: string[], ): Promise { if (!existsSync(/*turbopackIgnore: true*/ sourcePath)) return; for (const dir of dirs) { const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName); const sourceKey = process.platform === "win32" ? sourcePath.toLowerCase() : sourcePath; const targetKey = process.platform === "win32" ? targetPath.toLowerCase() : targetPath; if (sourceKey === targetKey) continue; try { await fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true }); await fs.copyFile( /*turbopackIgnore: true*/ sourcePath, /*turbopackIgnore: true*/ targetPath, ); } catch (err) { warnings?.push( `Mirror copy failed for ${fileName}: ${(err as Error).message}`, ); } } } export function buildItemsBaseEnrichmentUpdate(params: { id: number; dims: { x: number; y: number; z: number }; cansiton: boolean; canlayon: boolean; canstandon: boolean; allowStack: string; interactionType: string; }): SQL { return sql` UPDATE items_base SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z}, allow_sit = ${params.cansiton ? "1" : "0"}, allow_lay = ${params.canlayon ? "1" : "0"}, allow_walk = ${params.canstandon ? "1" : "0"}, allow_stack = ${params.allowStack}, interaction_type = ${params.interactionType} WHERE id = ${params.id} `; } /** Get or create the root "Imported Furniture" parent page (no items directly on this page). */ export async function getOrCreateImportedParentPage(): Promise { const [existing] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE)) .limit(1); if (existing) return existing.id; // Use raw SQL to avoid ENUM mismatch (page_layout, visible, enabled) // All string columns must be '' (not NULL) or the Java emulator may skip the page await db.execute(sql` INSERT INTO catalog_pages (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 (${IMPORTED_PAGE_CAPTION_SAVE}, ${IMPORTED_PAGE_CAPTION}, 'default_3x3', -1, '1', 9999, 263, '1', '1', '', '', '', '', '', '', '', '') `); const [created] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE)) .limit(1); if (!created) { throw new Error( 'Failed to create "Imported Furniture" parent catalog page', ); } return created.id; } /** Determine the catalog category for a furniture item based on its classname and type. */ export function classifyFurni(classname: string, itemType: string): string { for (const rule of CLASSNAME_RULES) { if (rule.test(classname, itemType)) return rule.cat; } return "other"; } /** * Auto-detect catalog price from classname. * - CF_750_xxx → cost_credits = 750 (credit furni = the credit value it represents) * - exe_50, exe_100, exe_200 etc. → cost_credits based on exchange value * - rare_xxx → cost_credits = 25 (rares are expensive) * - wf_xxx → cost_credits = 5 (wired components) * - everything else → cost_credits = 3 (default) * * Returns { credits, points, pointsType } */ export function autoPriceFurni(classname: string): { credits: number; points: number; pointsType: number; } { // CF__xxx → credit furniture, price = the credit value const cfMatch = classname.match(/^CF_(\d+)/i); if (cfMatch) { return { credits: parseInt(cfMatch[1], 10), points: 0, pointsType: 0 }; } // exe_ → exchange items (diamonds/duckets) const exeMatch = classname.match(/^exe_(\d+)/i); if (exeMatch) { return { credits: parseInt(exeMatch[1], 10), points: 0, pointsType: 0 }; } // Rares if (/^rare_/i.test(classname)) { return { credits: 25, points: 0, pointsType: 0 }; } // Wired if (/^wf_/i.test(classname)) { return { credits: 5, points: 0, pointsType: 0 }; } // NFT items if (/^nft_/i.test(classname)) { return { credits: 10, points: 0, pointsType: 0 }; } // Trophies if (/^(trophy|gold_|silver_|bronze_)/i.test(classname)) { return { credits: 10, points: 0, pointsType: 0 }; } // Default return { credits: 3, points: 0, pointsType: 0 }; } /** * Allocate the next `catalog_items.id` and run the INSERT through a * process-level async mutex so concurrent imports in the same Node process * cannot race on MAX(id). A DB-level lock would require pinning a single * connection for the whole operation; the in-process mutex is enough * for the single-server deployment and avoids pool-connection gymnastics. */ let catalogNextId: number | null = null; let catalogIdLastUsed = 0; let catalogIdSeedChain: Promise = Promise.resolve(); const CATALOG_ID_REFRESH_MS = 60_000; /** Allocate a unique catalog_items id. Seeded once with MAX(id)+1, then handed * out from an in-process counter so concurrent imports don't serialize on a * SELECT MAX + INSERT round-trip per item. Re-seeds when idle so rows added * externally don't collide. */ export async function allocateCatalogItemId( insertFn: (nextId: number) => Promise, ): Promise { const now = Date.now(); if ( catalogNextId === null || now - catalogIdLastUsed > CATALOG_ID_REFRESH_MS ) { let settle!: () => void; const prev = catalogIdSeedChain; catalogIdSeedChain = new Promise((r) => { settle = r; }); await prev.catch(() => {}); try { if ( catalogNextId === null || Date.now() - catalogIdLastUsed > CATALOG_ID_REFRESH_MS ) { const [maxIdResult] = (await db.execute(sql` SELECT MAX(id) as maxId FROM catalog_items `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; } } finally { settle(); } } catalogIdLastUsed = Date.now(); const nextId = catalogNextId; catalogNextId += 1; return await insertFn(nextId); } /** Test seam: drop the cached id counter between tests. */ export function __resetCatalogIdAllocatorForTests(): void { catalogNextId = null; catalogIdLastUsed = 0; catalogIdSeedChain = Promise.resolve(); } /** Get or create a category sub-page under the imported parent page. */ export async function getOrCreateCategoryPage( classname: string, itemType: string, ): Promise { const parentId = await getOrCreateImportedParentPage(); const catKey = classifyFurni(classname, itemType); const captionSave = `imp_${catKey}`; const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; const [existing] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where( and( eq(CatalogPages.captionSave, captionSave), eq(CatalogPages.parentId, parentId), ), ) .limit(1); if (existing) return existing.id; await db.execute(sql` INSERT INTO catalog_pages (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 (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '') `); const [created] = await db .select({ id: CatalogPages.id }) .from(CatalogPages) .where( and( eq(CatalogPages.captionSave, captionSave), eq(CatalogPages.parentId, parentId), ), ) .limit(1); if (!created) { throw new Error( `Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`, ); } return created.id; } // ── Import Single Furni ───────────────────────────────────────────── export async function importSingleFurni(params: { id: number; classname: string; name: string; description: string; type: string; revision: number; category: string; skipFurniDataWrite?: boolean; onProgress?: (status: string) => void; /** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */ sourceSwfBaseUrl?: string; /** Per-source nitro bundle + icon base URL. */ nitroBaseUrl?: string; iconBaseUrl?: string; }): Promise { const { id: originalId, classname, name, description, type, revision, category, skipFurniDataWrite, onProgress, sourceSwfBaseUrl, nitroBaseUrl: sourceNitroBaseUrl, iconBaseUrl: sourceIconBaseUrl, } = params; const warnings: string[] = []; // Check if already exists by classname OR by spriteId (primary key collision). const [existsByName] = await db .select({ id: ItemsBase.id }) .from(ItemsBase) .where(eq(ItemsBase.itemName, classname)) .limit(1); if (existsByName) { return { ok: false, warnings, error: `Item "${classname}" already exists (id: ${existsByName.id})`, }; } const [existsById] = await db .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .where(eq(ItemsBase.id, originalId)) .limit(1); if (existsById) { return { ok: false, warnings, error: `spriteId ${originalId} already used by "${existsById.itemName}"`, }; } const itemType = type === "wallitem" ? "i" : "s"; const spriteId = originalId; // ── Cross-reference with configured official Habbo furnidata ────── let officialHabbo: OfficialHabboFurniEntry | null = null; try { const hotel = await getHabboGamedataHotel(); officialHabbo = await lookupOfficialHabboFurni(classname); if (officialHabbo) { warnings.push(officialHabboEnrichmentWarning(hotel, officialHabbo.name)); } } catch { // Non-blocking: official Habbo enrichment is optional } // Best values: use configured official Habbo furnidata for enrichment. const bestName = name || officialHabbo?.name || classname; const bestDescription = description || officialHabbo?.description || ""; const bestRevision = revision || officialHabbo?.revision || 0; const bestCategory = category || officialHabbo?.category || "unknown"; // ── Insert into DB with enriched data ────────────────────────────── onProgress?.("writing_db"); try { // NOTE: live Arcturus `items_base` has no description/revision/rare columns — // those metadata fields are persisted in FurnitureData.json (see entry below), // not in the DB. Inserting them here throws "Unknown column 'description'". await db.execute(sql` INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType}) `); } catch (err) { return { ok: false, warnings, error: `DB insert failed: ${(err as Error).message}`, }; } // ── Derive file-safe names ──────────────────────────────────────── const rev = revision || 0; const assetTargets = await getFurniAssetWriteTargets(); const { swfDir, iconDir, nitroDir } = assetTargets; const starIdx = classname.indexOf("*"); const hasColor = starIdx !== -1; const baseClassname = hasColor ? classname.substring(0, starIdx) : classname; const iconFileName = classname.replace(/\*/g, "_"); const safeSwfName = baseClassname; const safeNitroName = baseClassname; const swfPath = path.join( /*turbopackIgnore: true*/ swfDir, `${safeSwfName}.swf`, ); const nitroPath = path.join( /*turbopackIgnore: true*/ nitroDir, `${safeNitroName}.nitro`, ); const iconPath = path.join( /*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`, ); const mirrorSwfDirs = assetTargets.mirrorDirs.map((d) => d.swfDir); const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir); const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir); // ── Download assets in parallel (with retry + validation) ──────── // Icon strategy matches Nitro's URL template: // `${hof.furni.url}/icons/%libname%%param%_icon.png` // For color variants (classname has `*`): try color-specific first, // fall back to base-classname icon (Nitro reuses base for all colors // when no color-specific icon exists). onProgress?.("downloading"); const iconColorAware = classname.replace(/\*/g, "_"); // Build candidate URL lists — source-specific URLs first, then Habbo CDN fallback const iconUrls: string[] = []; const swfUrls: string[] = []; // Source-specific icon URLs if (sourceIconBaseUrl) { // e.g. https://habblet.city/nitro/icons/{classname}_icon.png iconUrls.push( `${sourceIconBaseUrl}/${encodeURIComponent(iconColorAware)}_icon.png`, `${sourceIconBaseUrl}/${encodeURIComponent(baseClassname)}_icon.png`, ); } // Source-specific SWF URLs if (sourceSwfBaseUrl) { // e.g. https://virtualc.nl/dcr/hof_furni/{rev}/{classname}.swf swfUrls.push( `${sourceSwfBaseUrl}/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`, ); } // Official Habbo CDN fallbacks iconUrls.push( `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(iconColorAware)}_icon.png`, `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`, ); swfUrls.push( `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`, ); // Download icon (try all candidate URLs) let iconOk = false; for (const iconUrl of iconUrls) { const dl = await downloadFile(iconUrl, iconPath, { validate: "png" }); if (dl.ok) { iconOk = true; break; } } // Download SWF (try all candidate URLs) let swfOk = false; for (const swfUrl of swfUrls) { const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" }); if (dl.ok) { swfOk = true; break; } } // ── Nitro bundle fallback: try downloading pre-made .nitro ──────── // If SWF download failed, try downloading a pre-made .nitro from the // source's nitro base URL (many retro hotels ship .nitro bundles). let nitroDownloadOk = false; if ( !swfOk && sourceNitroBaseUrl && !existsSync(/*turbopackIgnore: true*/ nitroPath) ) { const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" }); if (dl.ok) nitroDownloadOk = true; } // ── Icon fallback: extract from SWF if download failed ──────────── if (!iconOk && (swfOk || existsSync(/*turbopackIgnore: true*/ swfPath))) { try { const swfBuf = await fs.readFile(/*turbopackIgnore: true*/ swfPath); const iconPng = extractIconFromSwf(swfBuf, baseClassname); if (iconPng) { await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng); iconOk = true; warnings.push("Icon extracted from SWF (download failed)"); } else { warnings.push( "Icon download failed and SWF icon extraction returned nothing", ); } } catch { warnings.push("Icon download failed and SWF icon extraction also failed"); } } else if (!iconOk) { warnings.push("Icon image download failed"); } if (!swfOk && !nitroDownloadOk) warnings.push("SWF download failed"); // ── Convert SWF to Nitro ────────────────────────────────────────── onProgress?.("converting"); let conversionResult: ConversionResult | null = null; if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { warnings.push("Nitro file already exists (shared across color variants)"); } else { try { if (existsSync(/*turbopackIgnore: true*/ swfPath)) { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); conversionResult = convertSwfToNitro(swfBuffer, baseClassname); await fs.writeFile( /*turbopackIgnore: true*/ nitroPath, conversionResult.bundle, ); if (conversionResult.warnings.length > 0) { warnings.push(...conversionResult.warnings); } } else { warnings.push("SWF not available for Nitro conversion"); } } catch (err) { logger.warn("[import-furni] SWF to Nitro conversion failed", { error: (err as Error).message, }); warnings.push( `SWF to Nitro conversion failed: ${(err as Error).message}`, ); } } // ── Rollback: if conversion/download failed AND no .nitro exists, remove DB record ── if ( !existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult && !nitroDownloadOk ) { try { await db.execute( sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`, ); } catch { /* best effort cleanup */ } return { ok: false, warnings, error: "Import rolled back: no .nitro file could be created", }; } await Promise.all([ mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings), mirrorFileToDirs( iconPath, `${iconFileName}_icon.png`, mirrorIconDirs, warnings, ), mirrorFileToDirs( nitroPath, `${safeNitroName}.nitro`, mirrorNitroDirs, warnings, ), ]); // ── Build enriched FurnitureData entry ──────────────────────────── const dims = conversionResult?.dimensions || (officialHabbo ? { x: officialHabbo.xdim, y: officialHabbo.ydim, z: 0 } : { x: 1, y: 1, z: 0 }); const meta = conversionResult?.metadata ?? null; // Prefer .nitro flags, then the configured official Habbo furnidata. const cansiton = meta?.cansiton ?? officialHabbo?.cansiton ?? false; const canlayon = meta?.canlayon ?? officialHabbo?.canlayon ?? false; const canstandon = meta?.canstandon ?? officialHabbo?.canstandon ?? false; // Derive interaction_type. Priority: // 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_) // — use the FULL classname so Arcturus can route to the specific // InteractionWired* handler. Collapsing to wf_act/wf_trg/wf_cnd // breaks every wired because the emulator looks up the exact type. // 2. .nitro logicType match for known mechanics. // 3. Non-logic wired (wf_firegate, wf_pressureplate, wf_pyramid, // wf_floor_switch, …) that didn't match step 2 — use the full // classname as a safe fallback instead of 'default'. // 4. Final fallback: default. const logicType = meta?.logicType || ""; let interactionType = "default"; if (/^wf_(act|trg|cnd|slc|xtra|var)_/.test(classname)) { interactionType = classname; } else if (logicType.includes("multistate")) interactionType = "default"; else if (logicType.includes("gate")) interactionType = "gate"; else if (logicType.includes("teleport")) interactionType = "teleport"; else if (logicType.includes("roller")) interactionType = "roller"; else if (logicType.includes("dice")) interactionType = "dice"; else if (logicType.includes("vendingmachine")) interactionType = "vendingmachine"; else if (logicType.includes("firework")) interactionType = "fireworks"; else if (logicType.includes("trophy")) interactionType = "trophy"; else if (logicType.includes("exchange")) interactionType = "exchange"; else if (logicType.includes("habbowheel")) interactionType = "habbowheel"; else if (logicType.includes("soundmachine")) interactionType = "jukebox"; else if (logicType.includes("badge")) interactionType = "badge"; if (interactionType === "default" && /^wf_/.test(classname)) { interactionType = classname; warnings.push( `Wired fallback: using full classname "${classname}" as interaction_type`, ); } // allow_stack: if it has height and you can put things on it const allowStack = dims.z > 0 ? "1" : "0"; // Update items_base with ALL enriched data try { const update = buildItemsBaseEnrichmentUpdate({ id: spriteId, dims, cansiton, canlayon, canstandon, allowStack, interactionType, }); await db.execute(update); } catch (err) { warnings.push( `items_base dimension update failed: ${(err as Error).message}`, ); } // Build enriched FurnitureData.json entry using best available data const furniEntry = buildFurniEntry({ id: spriteId, classname, revision: bestRevision, category: bestCategory, name: bestName, description: bestDescription, spriteId, dims, metadata: meta, officialHabbo, }); // ── Add to FurnitureData.json (or defer for batch) ──────────────── if (skipFurniDataWrite) { // Batch mode: return the entry so the caller writes once at the end } else { try { await appendFurniEntry(furniEntry, itemType); } catch (err) { logger.warn("[import-furni] Failed to update FurnitureData.json", { error: (err as Error).message, }); warnings.push("FurnitureData.json update failed"); } } // ── Create catalog entry (with duplicate check + category sub-page) ── let catalogItemId: number | null = null; try { const itemIdStr = String(spriteId); // Check if this item already exists anywhere in the catalog const [existingCatalog] = (await db.execute(sql` SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1 `)) as unknown as [Array<{ id: number; page_id: string }>, unknown]; if (existingCatalog.length > 0) { catalogItemId = existingCatalog[0].id; warnings.push( `Item already in catalog (catalog_item #${catalogItemId}, page ${existingCatalog[0].page_id}) — skipped duplicate`, ); } else { // Get or create the right category sub-page based on classname const categoryPageId = await getOrCreateCategoryPage(classname, itemType); const pageIdStr = String(categoryPageId); const price = autoPriceFurni(classname); // catalog_items.id has no AUTO_INCREMENT — serialize ID allocation // across concurrent imports via a MySQL named lock to avoid MAX(id) // TOCTOU that would cause duplicate-key failures. catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { await db.execute(sql` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '') `); return nextCatalogId; }); } } catch (err) { logger.warn("[import-furni] Failed to create catalog entry", { error: (err as Error).message, }); warnings.push("Catalog entry creation failed"); } return { ok: true, itemId: spriteId, catalogItemId, dimensions: dims, spriteCount: conversionResult?.spriteCount, warnings, furniDataEntry: skipFurniDataWrite ? { entry: furniEntry, itemType } : undefined, }; } // ── Delete helpers ─────────────────────────────────────────────────── /** Delete a single imported item: DB records, FurnitureData.json entry, and asset files. */ export async function deleteImportedItem(classname: string): Promise<{ spriteId: number; deletedFiles: string[]; errors: string[]; }> { const errors: string[] = []; // Look up item in DB const [item] = await db .select({ id: ItemsBase.id, type: ItemsBase.type }) .from(ItemsBase) .where(eq(ItemsBase.itemName, classname)) .limit(1); if (!item) { throw new Error(`Item "${classname}" not found in database`); } const spriteId = item.id; const starIdx = classname.indexOf("*"); const assetTargets = await getFurniAssetWriteTargets(); const { swfDir, iconDir, nitroDir } = assetTargets; const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname; const iconFileName = classname.replace(/\*/g, "_"); // 1. Delete catalog_items try { const itemIdStr = String(spriteId); await db.execute( sql`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`, ); } catch (err) { errors.push(`catalog_items: ${(err as Error).message}`); } // 2. Delete items_base try { await db.execute( sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`, ); } catch (err) { errors.push(`items_base: ${(err as Error).message}`); } // 3. Remove from FurnitureData.json try { await removeFurniEntry(classname); } catch (err) { errors.push(`FurnitureData.json: ${(err as Error).message}`); } // 4. Delete files (.nitro, .swf, icon) const filesToDelete = [ path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`), path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`), path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`), ...assetTargets.mirrorDirs.flatMap((dirs) => [ path.join( /*turbopackIgnore: true*/ dirs.nitroDir, `${baseClassname}.nitro`, ), path.join(/*turbopackIgnore: true*/ dirs.swfDir, `${baseClassname}.swf`), path.join( /*turbopackIgnore: true*/ dirs.iconDir, `${iconFileName}_icon.png`, ), ]), ]; const deletedFiles: string[] = []; for (const filePath of filesToDelete) { if (existsSync(/*turbopackIgnore: true*/ filePath)) { try { await fs.unlink(/*turbopackIgnore: true*/ filePath); deletedFiles.push(path.basename(filePath)); } catch (err) { errors.push(`${path.basename(filePath)}: ${(err as Error).message}`); } } } return { spriteId, deletedFiles, errors }; } /** Clean up SWF files that have already been converted to Nitro. */ export async function cleanupConvertedSwfs(): Promise<{ deleted: number; total: number; remaining: number; errors: string[]; }> { const { swfDir, nitroDir } = await getFurniAssetDirs(); if (!existsSync(swfDir)) { return { deleted: 0, total: 0, remaining: 0, errors: [] }; } const swfFiles = (await fs.readdir(swfDir)).filter((f) => f.endsWith(".swf")); let deleted = 0; const errors: string[] = []; for (const swfFile of swfFiles) { const cn = swfFile.replace(".swf", ""); const nitroFilePath = path.join( /*turbopackIgnore: true*/ nitroDir, `${cn}.nitro`, ); if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) { try { await fs.unlink(path.join(/*turbopackIgnore: true*/ swfDir, swfFile)); deleted++; } catch (err) { errors.push(`Failed to delete ${swfFile}: ${(err as Error).message}`); } } } return { deleted, total: swfFiles.length, remaining: swfFiles.length - deleted, errors, }; }