326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
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<Record<string, unknown>>
|
|
}
|
|
interface RemoteFurnidata {
|
|
roomitemtypes?: FurniType
|
|
wallitemtypes?: FurniType
|
|
furnitype?: Array<Record<string, unknown>>
|
|
}
|
|
|
|
// Spread ...raw first so the coerced typed fields always win over raw values.
|
|
function toSourceFurni(raw: Record<string, unknown>, 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<string, { list: SourceFurni[]; ts: number }>()
|
|
const TTL = 5 * 60 * 1000
|
|
|
|
export async function fetchSourceFurnidata(url: string, now = Date.now()): Promise<SourceFurni[]> {
|
|
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<unknown> = Promise.resolve()
|
|
|
|
async function allocateItemsBaseId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
|
|
const prev = itemsBaseIdAllocChain
|
|
let settle!: () => void
|
|
itemsBaseIdAllocChain = new Promise<void>((r) => {
|
|
settle = r
|
|
})
|
|
await prev.catch(() => {})
|
|
try {
|
|
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
|
|
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<CloneResult> {
|
|
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<Array<{ id: number }>>`
|
|
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<string, unknown>, 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<SourceFurni & { present: boolean }>
|
|
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<string>()
|
|
if (allNames.length) {
|
|
const placeholders = allNames.map(() => '?').join(',')
|
|
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
|
`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<string>()
|
|
if (classnames.length) {
|
|
const placeholders = classnames.map(() => '?').join(',')
|
|
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
|
`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<string[]> {
|
|
const all = await fetchSourceFurnidata(source.furnidataUrl)
|
|
const classnames = all.map((e) => e.classname)
|
|
const have = new Set<string>()
|
|
if (classnames.length) {
|
|
const placeholders = classnames.map(() => '?').join(',')
|
|
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
|
`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)
|
|
}
|