Files
EpicNext-Cms/src/lib/services/furni-import.ts
T
SimoandCursor 49d204e85c
Local Build and Deploy / deploy (push) Successful in 1m41s
Remove import/repair feature and related API routes.
Co-authored-by: Cursor <[email protected]>
2026-07-17 18:52:38 +02:00

852 lines
28 KiB
TypeScript

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_<key>`)
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<void> {
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<void> {
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<number | string> } {
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<number> {
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_<number>_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_<number> → 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<unknown> = Promise.resolve();
export async function allocateCatalogItemId<T>(
insertFn: (nextId: number) => Promise<T>,
): Promise<T> {
const prev = catalogIdAllocChain;
let settle!: () => void;
catalogIdAllocChain = new Promise<void>((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<number> {
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<ImportSingleResult> {
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,
};
}