style: clean up code with prettier and eslint

This commit is contained in:
openhands committed 2026-07-12 20:31:05 +02:00
1 parent 60278b9e71
commit fb8e77bb68
378 files changed
+22407 -20689

No files matched your search

+152 -159
View File
@@ -1,94 +1,93 @@
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 { 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'
} 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
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
ok: boolean;
classname: string;
skipped?: boolean;
warnings: string[];
error?: string;
}
interface FurniType {
furnitype?: Array<Record<string, unknown>>
furnitype?: Array<Record<string, unknown>>;
}
interface RemoteFurnidata {
roomitemtypes?: FurniType
wallitemtypes?: FurniType
furnitype?: Array<Record<string, unknown>>
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 {
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 ?? ''),
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 ?? ''),
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 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
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[]
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)
const json = await res.json();
list = parseFurnidata(json);
} catch (err) {
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`)
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`);
}
cache.set(url, { list, ts: now })
return list
cache.set(url, { list, ts: now });
return list;
}
/**
@@ -96,94 +95,94 @@ export async function fetchSourceFurnidata(url: string, now = Date.now()): Promi
* 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()
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
async function allocateItemsBaseId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
const prev = itemsBaseIdAllocChain
let settle!: () => void
const prev = itemsBaseIdAllocChain;
let settle!: () => void;
itemsBaseIdAllocChain = new Promise<void>((r) => {
settle = r
})
await prev.catch(() => {})
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)
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`;
const nextId = Number(idRow[0]?.next ?? 1);
return await insertFn(nextId);
} finally {
settle()
settle();
}
}
export async function cloneSingleFurni(params: {
source: CloneSource
entry: SourceFurni
onProgress?: (status: string) => void
source: CloneSource;
entry: SourceFurni;
onProgress?: (status: string) => void;
}): Promise<CloneResult> {
const { source, entry, onProgress } = params
const { classname, itemType } = entry
const warnings: string[] = []
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' }
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`
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' }
return { ok: false, classname, skipped: true, warnings, error: "Already present" };
}
await ensureDirectories()
const { iconDir, nitroDir } = await getFurniAssetDirs()
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`)
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' }
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))
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' }
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',
})
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))
const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
if (icon) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon)
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon);
} else {
warnings.push('no icon (not in source or bundle)')
warnings.push("no icon (not in source or bundle)");
}
} catch {
warnings.push('icon extraction failed')
warnings.push("icon extraction failed");
}
}
onProgress?.('writing_db')
const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0
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
const allowStack = stackHeight > 0 ? "1" : "0";
let newId: number;
try {
newId = await allocateItemsBaseId(async (nextId) => {
await prisma.$executeRaw`
@@ -192,117 +191,111 @@ export async function cloneSingleFurni(params: {
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
})
${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)
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')
onProgress?.("writing_furnidata");
try {
await appendFurniEntry({ ...entry, id: newId } as Record<string, unknown>, itemType)
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}`)
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)
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
})
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}`)
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 }
return { ok: true, classname, warnings };
}
export async function getCloneList(params: {
source: CloneSource
search: string
page: number
perPage: number
filter?: 'all' | 'missing' | 'present'
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 }
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 { 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
? 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>()
const allNames = searched.map((e) => e.classname);
const present = new Set<string>();
if (allNames.length) {
const placeholders = allNames.map(() => '?').join(',')
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)
);
for (const r of rows) present.add(r.item_name);
}
const filtered =
filter === 'missing'
filter === "missing"
? searched.filter((e) => !present.has(e.classname))
: filter === 'present'
: filter === "present"
? searched.filter((e) => present.has(e.classname))
: searched
: searched;
const total = filtered.length
const start = (Math.max(page, 1) - 1) * perPage
const slice = filtered.slice(start, start + perPage)
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>()
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 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)
);
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 }
const present = all.filter((e) => have.has(e.classname)).length;
return { total: all.length, present, clonable: all.length - present };
}
/**
@@ -310,16 +303,16 @@ export async function getCloneStats(
* 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>()
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 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)
);
for (const r of rows) have.add(r.item_name);
}
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname)
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
}