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 (and furni-repair) 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 = { 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>` 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>` 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, } }