Files
EpicNext-Cms/src/lib/services/furni-import.ts
T
openhands 86d59195ec Exclude badge items from catalog audit and repair checks
Badge items use .gif files, not .nitro bundles, so they should not
be flagged as missing .nitro or missing catalog entries. Also add
badge logicType handling in furni-import.ts so badges get the correct
interaction_type when imported.
2026-08-03 21:23:13 +02:00

860 lines
29 KiB
TypeScript

import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import {
appendFurniEntry,
buildFurniEntry,
removeFurniEntry,
} from "@/lib/services/furni-data";
import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache";
import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
// 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 {
return sql`
UPDATE items_base
SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z},
allow_sit = ${params.cansiton ? "1" : "0"},
allow_lay = ${params.canlayon ? "1" : "0"},
allow_walk = ${params.canstandon ? "1" : "0"},
allow_stack = ${params.allowStack},
interaction_type = ${params.interactionType}
WHERE id = ${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 db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE))
.limit(1);
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 db.execute(sql`
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 db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE))
.limit(1);
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
* 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 db.execute(sql`
SELECT MAX(id) as maxId FROM catalog_items
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
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 db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(
and(
eq(CatalogPages.captionSave, captionSave),
eq(CatalogPages.parentId, parentId),
),
)
.limit(1);
if (existing) return existing.id;
await db.execute(sql`
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 db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(
and(
eq(CatalogPages.captionSave, captionSave),
eq(CatalogPages.parentId, parentId),
),
)
.limit(1);
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 db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, classname))
.limit(1);
if (existsByName) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
};
}
const [existsById] = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(eq(ItemsBase.id, originalId))
.limit(1);
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 configured official Habbo furnidata ──────
let officialHabbo: OfficialHabboFurniEntry | null = null;
try {
const hotel = await getHabboGamedataHotel();
officialHabbo = await lookupOfficialHabboFurni(classname);
if (officialHabbo) {
warnings.push(officialHabboEnrichmentWarning(hotel, officialHabbo.name));
}
} catch {
// Non-blocking: official Habbo enrichment is optional
}
// Best values: use configured official Habbo furnidata for enrichment.
const bestName = name || officialHabbo?.name || classname;
const bestDescription = description || officialHabbo?.description || "";
const bestRevision = revision || officialHabbo?.revision || 0;
const bestCategory = category || officialHabbo?.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 db.execute(sql`
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 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) {
logger.warn("[import-furni] SWF to Nitro conversion failed", {
error: (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 db.execute(
sql`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 ||
(officialHabbo
? { x: officialHabbo.xdim, y: officialHabbo.ydim, z: 0 }
: { x: 1, y: 1, z: 0 });
const meta = conversionResult?.metadata ?? null;
// Prefer .nitro flags, then the configured official Habbo furnidata.
const cansiton = meta?.cansiton ?? officialHabbo?.cansiton ?? false;
const canlayon = meta?.canlayon ?? officialHabbo?.canlayon ?? false;
const canstandon = meta?.canstandon ?? officialHabbo?.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";
else if (logicType.includes("badge")) interactionType = "badge";
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 db.execute(update);
} 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,
officialHabbo,
});
// ── 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) {
logger.warn("[import-furni] Failed to update FurnitureData.json", {
error: (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 db.execute(sql`
SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1
`)) as unknown as [Array<{ id: number; page_id: string }>, unknown];
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 db.execute(sql`
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) {
logger.warn("[import-furni] Failed to create catalog entry", {
error: (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 db
.select({ id: ItemsBase.id, type: ItemsBase.type })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, classname))
.limit(1);
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 db.execute(
sql`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 db.execute(
sql`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,
};
}