import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { prisma } from "@/lib/prisma"; import { appendFurniEntry, buildFurniEntry, removeFurniEntry, } from "@/lib/services/furni-data"; import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache"; import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache"; // 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: string; values: Array } { return { sql: ` UPDATE items_base SET width = ?, length = ?, stack_height = ?, allow_sit = ?, allow_lay = ?, allow_walk = ?, allow_stack = ?, interaction_type = ? WHERE id = ? `, values: [ params.dims.x, params.dims.y, params.dims.z, params.cansiton ? "1" : "0", params.canlayon ? "1" : "0", params.canstandon ? "1" : "0", params.allowStack, params.interactionType, 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 prisma.catalogPages.findFirst({ where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE }, select: { id: true }, }); 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 prisma.$executeRaw` 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 prisma.catalogPages.findFirst({ where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE }, select: { id: true }, }); 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 * Prisma connection for the whole operation; the in-process mutex is enough * for the single-server deployment and avoids pool-connection gymnastics. */ let catalogIdAllocChain: Promise = Promise.resolve(); export async function allocateCatalogItemId( insertFn: (nextId: number) => Promise, ): Promise { const prev = catalogIdAllocChain; let settle!: () => void; catalogIdAllocChain = new Promise((r) => { settle = r; }); await prev.catch(() => {}); try { const maxIdResult = await prisma.$queryRaw< Array<{ maxId: number | bigint | null }> >` SELECT MAX(id) as maxId FROM catalog_items `; const nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; return await insertFn(nextId); } finally { settle(); } } /** 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 prisma.catalogPages.findFirst({ where: { captionSave, parentId }, select: { id: true }, }); if (existing) return existing.id; await prisma.$executeRaw` 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 prisma.catalogPages.findFirst({ where: { captionSave, parentId }, select: { id: true }, }); 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; }): Promise { const { id: originalId, classname, name, description, type, revision, category, skipFurniDataWrite, onProgress, } = params; const warnings: string[] = []; // Check if already exists by classname OR by spriteId (primary key collision). const existsByName = await prisma.itemsBase.findFirst({ where: { itemName: classname }, select: { id: true }, }); if (existsByName) { return { ok: false, warnings, error: `Item "${classname}" already exists (id: ${existsByName.id})`, }; } const existsById = await prisma.itemsBase.findUnique({ where: { id: originalId }, select: { itemName: true }, }); 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 habbo.it furnidata for enrichment ──────── let habboIt: HabboItFurniEntry | null = null; try { habboIt = await lookupHabboIt(classname); if (habboIt) { warnings.push(`Enriched from habbo.it: "${habboIt.name}"`); } } catch { // Non-blocking: habbo.it enrichment is optional } // Best values: use habbo.it furnidata for enrichment const bestName = name || habboIt?.name || classname; const bestDescription = description || habboIt?.description || ""; const bestRevision = revision || habboIt?.revision || 0; const bestCategory = category || habboIt?.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 prisma.$executeRaw` 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 _colorNum = hasColor ? classname.substring(starIdx + 1) : "2"; 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, "_"); const iconUrl = hasColor ? `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(iconColorAware)}_icon.png` : `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`; const downloadResults = await Promise.allSettled([ downloadFile(iconUrl, iconPath, { validate: "png" }), downloadFile( `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`, swfPath, { validate: "swf" }, ), ]); let iconOk = downloadResults[0].status === "fulfilled" && downloadResults[0].value.ok; const swfOk = downloadResults[1].status === "fulfilled" && downloadResults[1].value.ok; // If color-specific icon 404'd, try the base-classname icon. if (!iconOk && hasColor) { const dl = await downloadFile( `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`, iconPath, { validate: "png" }, ); if (dl.ok) iconOk = 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); warnings.push("Icon extracted from SWF (external 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) 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) { console.warn( "[import-furni] SWF to Nitro conversion failed:", (err as Error).message, ); warnings.push( `SWF to Nitro conversion failed: ${(err as Error).message}`, ); } } // ── Rollback: if conversion failed AND no .nitro exists, remove DB record ── if (!existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult) { try { await prisma.$executeRaw`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 || (habboIt ? { x: habboIt.xdim, y: habboIt.ydim, z: 0 } : { x: 1, y: 1, z: 0 }); const meta = conversionResult?.metadata ?? null; // Merge interaction flags: prefer .nitro extraction, fallback to habbo.it const cansiton = meta?.cansiton ?? habboIt?.cansiton ?? false; const canlayon = meta?.canlayon ?? habboIt?.canlayon ?? false; const canstandon = meta?.canstandon ?? habboIt?.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"; 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 prisma.$executeRawUnsafe(update.sql, ...update.values); } 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, habboIt, }); // ── 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) { console.warn( "[import-furni] Failed to update FurnitureData.json:", (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 prisma.$queryRaw< Array<{ id: number; page_id: string }> >` SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1 `; 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 prisma.$executeRaw` 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) { console.warn( "[import-furni] Failed to create catalog entry:", (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 prisma.itemsBase.findFirst({ where: { itemName: classname }, select: { id: true, type: true }, }); 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 prisma.$executeRaw`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 prisma.$executeRaw`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, }; }