import { promises as fs } from 'node:fs' import path from 'node:path' import { prisma } from '@/lib/prisma' import { extractFurniIconPng } from '@/lib/services/clone-icon' import type { CloneSource } from '@/lib/services/clone-sources' import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs' import { appendFurniEntry } from '@/lib/services/furni-data' import { allocateCatalogItemId, autoPriceFurni, ensureDirectories, getOrCreateCategoryPage, } from '@/lib/services/furni-import' import { downloadFile } from '@/lib/services/import/core/download' import { parseNitroBundle } from '@/lib/services/swf/nitro-builder' export interface SourceFurni { id: number classname: string name: string description: string xdim: number ydim: number canstandon: boolean cansiton: boolean canlayon: boolean customparams: string itemType: 's' | 'i' [k: string]: unknown } export interface CloneResult { ok: boolean classname: string skipped?: boolean warnings: string[] error?: string } interface FurniType { furnitype?: Array> } interface RemoteFurnidata { roomitemtypes?: FurniType wallitemtypes?: FurniType furnitype?: Array> } // Spread ...raw first so the coerced typed fields always win over raw values. function toSourceFurni(raw: Record, itemType: 's' | 'i'): SourceFurni { return { ...raw, id: Number(raw.id ?? 0), classname: String(raw.classname ?? ''), name: String(raw.name ?? raw.classname ?? ''), description: String(raw.description ?? ''), xdim: Number(raw.xdim ?? 1), ydim: Number(raw.ydim ?? 1), canstandon: raw.canstandon === true, cansiton: raw.cansiton === true, canlayon: raw.canlayon === true, customparams: String(raw.customparams ?? ''), itemType, } } export function parseFurnidata(data: RemoteFurnidata): SourceFurni[] { const out: SourceFurni[] = [] for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? []) out.push(toSourceFurni(r, 's')) for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, 'i')) return out.filter((e) => e.classname) } const cache = new Map() const TTL = 5 * 60 * 1000 export async function fetchSourceFurnidata(url: string, now = Date.now()): Promise { const hit = cache.get(url) if (hit && now && now - hit.ts < TTL) return hit.list const res = await fetch(url, { signal: AbortSignal.timeout(30000) }) if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`) let list: SourceFurni[] try { const json = await res.json() list = parseFurnidata(json) } catch (err) { throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`) } cache.set(url, { list, ts: now }) return list } /** * Allocate the next `items_base.id` through a process-level async mutex so * concurrent clones in the same Node process cannot race on MAX(id)+1. * Mirrors the `allocateCatalogItemId` pattern from furni-import.ts. */ let itemsBaseIdAllocChain: Promise = Promise.resolve() async function allocateItemsBaseId(insertFn: (nextId: number) => Promise): Promise { const prev = itemsBaseIdAllocChain let settle!: () => void itemsBaseIdAllocChain = new Promise((r) => { settle = r }) await prev.catch(() => {}) try { const idRow = await prisma.$queryRaw>` SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base` const nextId = Number(idRow[0]?.next ?? 1) return await insertFn(nextId) } finally { settle() } } export async function cloneSingleFurni(params: { source: CloneSource entry: SourceFurni onProgress?: (status: string) => void }): Promise { const { source, entry, onProgress } = params const { classname, itemType } = entry const warnings: string[] = [] // Path-traversal guard: classname comes from remote furnidata and is used to // build file paths — reject anything that doesn't look like a safe furni name. if (!/^[\w\-.*]+$/.test(classname)) { return { ok: false, classname, warnings, error: 'invalid classname' } } // Dedup by classname. const existing = await prisma.$queryRaw>` SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1` if (existing.length > 0) { return { ok: false, classname, skipped: true, warnings, error: 'Already present' } } await ensureDirectories() const { iconDir, nitroDir } = await getFurniAssetDirs() // Download .nitro + icon directly from the source hotel. onProgress?.('downloading') const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`) const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`) const dl = await downloadFile(`${source.nitroBaseUrl}/${classname}.nitro`, nitroPath, { maxRetries: 2, }) if (!dl.ok) { console.warn('[clone-import] nitro download failed for', classname) return { ok: false, classname, warnings, error: 'nitro download failed' } } // Validate it is a real Nitro bundle. try { parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath)) } catch { await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {}) console.warn('[clone-import] invalid .nitro bundle for', classname) return { ok: false, classname, warnings, error: 'invalid .nitro bundle' } } const iconDl = await downloadFile(`${source.iconBaseUrl}/${classname}_icon.png`, iconPath, { maxRetries: 1, validate: 'png', }) if (!iconDl.ok) { // Source serves no standalone icon (e.g. icons embedded in the .nitro) — // extract the catalog icon from the bundle we just downloaded. try { const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath)) if (icon) { await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon) } else { warnings.push('no icon (not in source or bundle)') } } catch { warnings.push('icon extraction failed') } } onProgress?.('writing_db') const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0 // allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`). // allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon. const allowStack = stackHeight > 0 ? '1' : '0' let newId: number try { newId = await allocateItemsBaseId(async (nextId) => { await prisma.$executeRaw` INSERT INTO items_base (id, sprite_id, public_name, item_name, type, width, length, stack_height, allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams) VALUES (${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight}, ${allowStack}, ${entry.cansiton ? '1' : '0'}, ${entry.canlayon ? '1' : '0'}, ${entry.canstandon ? '1' : '0'}, 'default', ${entry.customparams})` return nextId }) } catch (err) { // Rollback: remove both downloaded files so we don't leave orphaned assets. await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {}) await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {}) console.warn('[clone-import] items_base insert failed for', classname, (err as Error).message) return { ok: false, classname, warnings, error: `items_base insert failed: ${(err as Error).message}`, } } // FurnitureData entry — reuse the source's furnitype object, with our id. onProgress?.('writing_furnidata') try { await appendFurniEntry({ ...entry, id: newId } as Record, itemType) } catch (err) { console.warn( '[clone-import] FurnitureData append failed for', classname, (err as Error).message, ) warnings.push(`FurnitureData append failed: ${(err as Error).message}`) } // Catalog entry (category sub-page + auto price), serialized id allocation. try { const pageId = await getOrCreateCategoryPage(classname, itemType) const price = autoPriceFurni(classname) 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}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')` return nextCatalogId }) } catch (err) { console.warn('[clone-import] catalog entry failed for', classname, (err as Error).message) warnings.push(`catalog entry failed: ${(err as Error).message}`) } return { ok: true, classname, warnings } } export async function getCloneList(params: { source: CloneSource search: string page: number perPage: number filter?: 'all' | 'missing' | 'present' }): Promise<{ items: Array meta: { page: number; perPage: number; total: number } }> { const { source, search, page, perPage, filter = 'all' } = params const all = await fetchSourceFurnidata(source.furnidataUrl) const term = search.trim().toLowerCase() const searched = term ? all.filter( (e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term), ) : all // Resolve present status across the whole searched list (not just the page), // so the missing/present filter and the page total stay correct. const allNames = searched.map((e) => e.classname) const present = new Set() if (allNames.length) { const placeholders = allNames.map(() => '?').join(',') const rows = await prisma.$queryRawUnsafe>( `SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`, ...allNames, ) for (const r of rows) present.add(r.item_name) } const filtered = filter === 'missing' ? searched.filter((e) => !present.has(e.classname)) : filter === 'present' ? searched.filter((e) => present.has(e.classname)) : searched const total = filtered.length const start = (Math.max(page, 1) - 1) * perPage const slice = filtered.slice(start, start + perPage) return { items: slice.map((e) => ({ ...e, present: present.has(e.classname) })), meta: { page: Math.max(page, 1), perPage, total }, } } export async function getCloneStats( source: CloneSource, ): Promise<{ total: number; present: number; clonable: number }> { const all = await fetchSourceFurnidata(source.furnidataUrl) const classnames = all.map((e) => e.classname) const have = new Set() if (classnames.length) { const placeholders = classnames.map(() => '?').join(',') const rows = await prisma.$queryRawUnsafe>( `SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`, ...classnames, ) for (const r of rows) have.add(r.item_name) } const present = all.filter((e) => have.has(e.classname)).length return { total: all.length, present, clonable: all.length - present } } /** * Return the classnames of every furni in the source that is NOT yet present * in our items_base — used by the "clone all" action (cloned in chunks by the UI). */ export async function getClonableClassnames(source: CloneSource): Promise { const all = await fetchSourceFurnidata(source.furnidataUrl) const classnames = all.map((e) => e.classname) const have = new Set() if (classnames.length) { const placeholders = classnames.map(() => '?').join(',') const rows = await prisma.$queryRawUnsafe>( `SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`, ...classnames, ) for (const r of rows) have.add(r.item_name) } return all.filter((e) => !have.has(e.classname)).map((e) => e.classname) }