Recognize imported furniture by normalized classname and local IDs
CI / check (push) Successful in 1m30s
CI / deploy (push) Successful in 1m21s
CI / e2e (push) Successful in 22s

This commit is contained in:
Simo committed 2026-09-05 14:04:52 +02:00
1 parent 9dc6b8a261
commit a78d8256e0
7 files changed
+254 -46

No files matched your search

+51 -22
View File
@@ -4,6 +4,11 @@ import { eq, sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response"; import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import {
indexLocalFurniture,
localFurnitureStatus,
} from "@/lib/furni/local-presence";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { getCloneList } from "@/lib/services/clone-import"; import { getCloneList } from "@/lib/services/clone-import";
@@ -298,6 +303,11 @@ export const GET = withAdmin(
const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
// ── Browse a custom clone source (retro hotel furnidata) ──────── // ── Browse a custom clone source (retro hotel furnidata) ────────
const localRows = await db
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
.from(ItemsBase);
const localIndex = indexLocalFurniture(localRows);
const importedSet = new Set(localIndex.keys());
const sourceId = request.nextUrl.searchParams.get("source"); const sourceId = request.nextUrl.searchParams.get("source");
if (sourceId) { if (sourceId) {
const source = await getSource(sourceId); const source = await getSource(sourceId);
@@ -325,7 +335,7 @@ export const GET = withAdmin(
...(fd.roomitemtypes?.furnitype ?? []), ...(fd.roomitemtypes?.furnitype ?? []),
...(fd.wallitemtypes?.furnitype ?? []), ...(fd.wallitemtypes?.furnitype ?? []),
]) { ]) {
if (e.classname) furniDataSet.add(e.classname); if (e.classname) furniDataSet.add(normalizeClassname(e.classname));
} }
} catch { } catch {
/* best effort */ /* best effort */
@@ -345,7 +355,13 @@ export const GET = withAdmin(
} }
const enriched = items.map((item) => { const enriched = items.map((item) => {
const imported = item.present; const presence = localFurnitureStatus(
item.classname,
localIndex,
furniDataSet,
catalogIdSet,
);
const imported = presence.alreadyImported;
let nitroExists = false; let nitroExists = false;
if (imported) { if (imported) {
const starIdx = item.classname.indexOf("*"); const starIdx = item.classname.indexOf("*");
@@ -357,7 +373,17 @@ export const GET = withAdmin(
/*turbopackIgnore: true*/ nitroDir, /*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`, `${base}.nitro`,
); );
nitroExists = existsSync(/*turbopackIgnore: true*/ p); nitroExists =
existsSync(/*turbopackIgnore: true*/ p) ||
(localIndex.get(normalizeClassname(item.classname)) ?? []).some(
(row) =>
existsSync(
path.join(
/*turbopackIgnore: true*/ nitroDir,
`${row.itemName.split("*")[0]}.nitro`,
),
),
);
} }
return { return {
id: item.id, id: item.id,
@@ -367,10 +393,9 @@ export const GET = withAdmin(
description: item.description, description: item.description,
revision: 0, revision: 0,
category: String(item.category ?? ""), category: String(item.category ?? ""),
alreadyImported: imported, ...presence,
nitroExists, nitroExists,
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
iconUrl: sourceIconBase iconUrl: sourceIconBase
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png` ? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
: undefined, : undefined,
@@ -389,16 +414,6 @@ export const GET = withAdmin(
}); });
} }
// Pull every imported classname once so we can both (a) sort non-imported
// items to the top *before* pagination and (b) skip the per-page lookup
// below. Single-column indexed query — fast even at 10k+ rows.
const allImported = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase);
const importedSet = new Set(
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
);
// Bulk-load FurnitureData and catalog references for status checks // Bulk-load FurnitureData and catalog references for status checks
const furniDataSet = new Set<string>(); const furniDataSet = new Set<string>();
const catalogIdSet = new Set<number>(); const catalogIdSet = new Set<number>();
@@ -411,7 +426,7 @@ export const GET = withAdmin(
...(fd.roomitemtypes?.furnitype ?? []), ...(fd.roomitemtypes?.furnitype ?? []),
...(fd.wallitemtypes?.furnitype ?? []), ...(fd.wallitemtypes?.furnitype ?? []),
]) { ]) {
if (e.classname) furniDataSet.add(e.classname); if (e.classname) furniDataSet.add(normalizeClassname(e.classname));
} }
} catch { } catch {
/* best effort */ /* best effort */
@@ -438,7 +453,13 @@ export const GET = withAdmin(
}); });
const enriched = result.items.map((item) => { const enriched = result.items.map((item) => {
const imported = importedSet.has(item.classname); const presence = localFurnitureStatus(
item.classname,
localIndex,
furniDataSet,
catalogIdSet,
);
const imported = presence.alreadyImported;
let nitroExists = false; let nitroExists = false;
if (imported) { if (imported) {
const starIdx = item.classname.indexOf("*"); const starIdx = item.classname.indexOf("*");
@@ -450,14 +471,22 @@ export const GET = withAdmin(
/*turbopackIgnore: true*/ nitroDir, /*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`, `${base}.nitro`,
); );
nitroExists = existsSync(/*turbopackIgnore: true*/ p); nitroExists =
existsSync(/*turbopackIgnore: true*/ p) ||
(localIndex.get(normalizeClassname(item.classname)) ?? []).some(
(row) =>
existsSync(
path.join(
/*turbopackIgnore: true*/ nitroDir,
`${row.itemName.split("*")[0]}.nitro`,
),
),
);
} }
return { return {
...item, ...item,
alreadyImported: imported, ...presence,
nitroExists, nitroExists,
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
}; };
}); });
@@ -94,4 +94,50 @@ describe("furniture inspection", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect((await response.json()).items[0].furnidataReadable).toBe(false); expect((await response.json()).items[0].furnidataReadable).toBe(false);
}); });
it("recognizes existing SQL and furnidata across classname casing", async () => {
mocks.execute
.mockResolvedValueOnce([
[
{
id: 2000037258,
spriteId: 2000037258,
classname: "recycler_kintsugib",
name: "Local",
type: "s",
},
],
[],
])
.mockResolvedValueOnce([
[
{
id: 3,
pageId: 4,
classname: "RECYCLER_KINTSUGIB",
credits: 3,
points: 0,
},
],
[],
]);
mocks.read.mockResolvedValue({
roomitemtypes: {
furnitype: [
{ id: 2000037258, classname: "recycler_kintsugib", name: "Local" },
],
},
wallitemtypes: { furnitype: [] },
});
mocks.stat.mockImplementation(async (filename) => {
if (String(filename).includes("kintsugib"))
return { size: 256, isFile: () => true };
throw { code: "ENOENT" };
});
const result = await (await POST(request(["recycler_kintsugiB"]))).json();
expect(result.items[0].sql[0].id).toBe(2000037258);
expect(result.items[0].catalog).toHaveLength(1);
expect(result.items[0].furnidata).toHaveLength(1);
expect(result.items[0].nitro.exists).toBe(true);
expect(result.items[0].icon.exists).toBe(true);
});
}); });
+51 -13
View File
@@ -4,6 +4,7 @@ import { sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response"; import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import { import {
type FurnitureInspection, type FurnitureInspection,
validateClassnames, validateClassnames,
@@ -12,7 +13,10 @@ import { PERMS } from "@/lib/permission-slugs";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { readFurniData } from "@/lib/services/furni-data"; import { readFurniData } from "@/lib/services/furni-data";
async function asset(directory: string, name: string) { async function asset(
directory: string,
name: string,
): Promise<{ exists: boolean | null; bytes: number }> {
try { try {
const stat = await fs.stat(path.join(directory, name)); const stat = await fs.stat(path.join(directory, name));
return { exists: stat.isFile(), bytes: stat.size }; return { exists: stat.isFile(), bytes: stat.size };
@@ -23,6 +27,16 @@ async function asset(directory: string, name: string) {
}; };
} }
} }
async function assetCandidates(directory: string, names: string[]) {
const results = await Promise.all(
[...new Set(names)].map((name) => asset(directory, name)),
);
return (
results.find((result) => result.exists === true) ??
results.find((result) => result.exists === null) ??
results[0]
);
}
// Read-only inspection lives outside import routes so it cannot enqueue a Git export. // Read-only inspection lives outside import routes so it cannot enqueue a Git export.
export const POST = withAdmin( export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
@@ -32,17 +46,17 @@ export const POST = withAdmin(
if (!names) if (!names)
return apiError("Provide 1–500 valid furniture classnames", 400); return apiError("Provide 1–500 valid furniture classnames", 400);
const namesSql = sql.join( const namesSql = sql.join(
names.map((name) => sql`${name}`), names.map((name) => sql`${normalizeClassname(name)}`),
sql`, `, sql`, `,
); );
const [rows] = (await db.execute( const [rows] = (await db.execute(
sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE item_name IN (${namesSql})`, sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE LOWER(TRIM(item_name)) IN (${namesSql})`,
)) as unknown as [ )) as unknown as [
Array<FurnitureInspection["sql"][number] & { classname: string }>, Array<FurnitureInspection["sql"][number] & { classname: string }>,
unknown, unknown,
]; ];
const [offers] = (await db.execute( const [offers] = (await db.execute(
sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE ib.item_name IN (${namesSql})`, sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE LOWER(TRIM(ib.item_name)) IN (${namesSql})`,
)) as unknown as [ )) as unknown as [
Array<FurnitureInspection["catalog"][number] & { classname: string }>, Array<FurnitureInspection["catalog"][number] & { classname: string }>,
unknown, unknown,
@@ -74,8 +88,9 @@ export const POST = withAdmin(
continue; continue;
} }
const name = String(entry.classname ?? ""); const name = String(entry.classname ?? "");
if (!names.includes(name)) continue; const key = normalizeClassname(name);
const values = entries.get(name) ?? []; if (!names.some((value) => normalizeClassname(value) === key)) continue;
const values = entries.get(key) ?? [];
values.push({ values.push({
id: Number(entry.id), id: Number(entry.id),
name: String(entry.name ?? ""), name: String(entry.name ?? ""),
@@ -83,7 +98,7 @@ export const POST = withAdmin(
revision: Number(entry.revision ?? 0), revision: Number(entry.revision ?? 0),
type, type,
}); });
entries.set(name, values); entries.set(key, values);
} }
} }
const dirs = await getFurniAssetDirs(); const dirs = await getFurniAssetDirs();
@@ -92,16 +107,39 @@ export const POST = withAdmin(
items.push( items.push(
...(await Promise.all( ...(await Promise.all(
names.slice(offset, offset + 20).map(async (classname) => { names.slice(offset, offset + 20).map(async (classname) => {
const base = classname.split("*")[0]; const variants = [
classname,
...rows
.filter(
(row) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
)
.map((row) => row.classname),
];
const [nitro, icon] = await Promise.all([ const [nitro, icon] = await Promise.all([
asset(dirs.nitroDir, `${base}.nitro`), assetCandidates(
asset(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`), dirs.nitroDir,
variants.map((name) => `${name.split("*")[0]}.nitro`),
),
assetCandidates(
dirs.iconDir,
variants.map((name) => `${name.replaceAll("*", "_")}_icon.png`),
),
]); ]);
return { return {
classname, classname,
sql: rows.filter((row) => row.classname === classname), sql: rows.filter(
catalog: offers.filter((row) => row.classname === classname), (row) =>
furnidata: entries.get(classname) ?? [], normalizeClassname(row.classname) ===
normalizeClassname(classname),
),
catalog: offers.filter(
(row) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
),
furnidata: entries.get(normalizeClassname(classname)) ?? [],
furnidataReadable: readable, furnidataReadable: readable,
nitro, nitro,
icon, icon,
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { indexLocalFurniture, localFurnitureStatus } from "./local-presence";
const index = indexLocalFurniture([
{ id: 2000037258, itemName: "recycler_kintsugib" },
]);
describe("local furniture recognition", () => {
it("recognizes casing differences and uses the allocated catalog ID", () => {
expect(
localFurnitureStatus(
"recycler_kintsugiB",
index,
new Set(["recycler_kintsugib"]),
new Set([2000037258]),
),
).toEqual({
alreadyImported: true,
localId: 2000037258,
hasFurnidata: true,
hasCatalogEntry: true,
});
});
it("does not confuse the base furniture with its named variant", () => {
expect(
localFurnitureStatus(
"recycler_kintsugi",
index,
new Set(),
new Set([16940]),
).alreadyImported,
).toBe(false);
});
it("does not identify a source-ID collision as a matching catalog offer", () => {
expect(
localFurnitureStatus(
"recycler_kintsugiB",
index,
new Set(),
new Set([16940]),
).hasCatalogEntry,
).toBe(false);
});
it("retains separate color variants and duplicate SQL records", () => {
const rows = indexLocalFurniture([
{ id: 1, itemName: "chair*0" },
{ id: 2, itemName: "CHAIR*0" },
{ id: 3, itemName: "chair*1" },
]);
expect(rows.get("chair*0")).toHaveLength(2);
expect(rows.get("chair*1")).toHaveLength(1);
expect(
localFurnitureStatus("chair*0", rows, new Set(), new Set()).localId,
).toBeUndefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { normalizeClassname } from "./classname";
export function indexLocalFurniture<T extends { id: number; itemName: string }>(
rows: T[],
) {
const index = new Map<string, T[]>();
for (const row of rows) {
const key = normalizeClassname(row.itemName);
const matches = index.get(key) ?? [];
matches.push(row);
index.set(key, matches);
}
return index;
}
export function localFurnitureStatus(
classname: string,
local: Map<string, Array<{ id: number; itemName: string }>>,
furnidata: Set<string>,
catalogIds: Set<number>,
) {
const key = normalizeClassname(classname);
const rows = local.get(key) ?? [];
return {
alreadyImported: rows.length > 0,
localId: rows.length === 1 ? rows[0].id : undefined,
hasFurnidata: furnidata.has(key),
hasCatalogEntry: rows.some((row) => catalogIds.has(row.id)),
};
}
+16 -8
View File
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
import { eq, inArray, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { normalizeClassname } from "@/lib/furni/classname";
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel"; import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { extractFurniIconPng } from "@/lib/services/clone-icon";
@@ -510,21 +511,24 @@ export async function getCloneList(params: {
.select({ itemName: ItemsBase.itemName }) .select({ itemName: ItemsBase.itemName })
.from(ItemsBase) .from(ItemsBase)
.where(inArray(ItemsBase.itemName, allNames)); .where(inArray(ItemsBase.itemName, allNames));
for (const r of rows) present.add(r.itemName); for (const r of rows) present.add(normalizeClassname(r.itemName));
} }
const filtered = const filtered =
filter === "missing" filter === "missing"
? searched.filter((e) => !present.has(e.classname)) ? searched.filter((e) => !present.has(normalizeClassname(e.classname)))
: filter === "present" : filter === "present"
? searched.filter((e) => present.has(e.classname)) ? searched.filter((e) => present.has(normalizeClassname(e.classname)))
: searched; : searched;
const total = filtered.length; const total = filtered.length;
const start = (Math.max(page, 1) - 1) * perPage; const start = (Math.max(page, 1) - 1) * perPage;
const slice = filtered.slice(start, start + perPage); const slice = filtered.slice(start, start + perPage);
return { return {
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })), items: slice.map((e) => ({
...e,
present: present.has(normalizeClassname(e.classname)),
})),
meta: { page: Math.max(page, 1), perPage, total }, meta: { page: Math.max(page, 1), perPage, total },
}; };
} }
@@ -540,9 +544,11 @@ export async function getCloneStats(
.select({ itemName: ItemsBase.itemName }) .select({ itemName: ItemsBase.itemName })
.from(ItemsBase) .from(ItemsBase)
.where(inArray(ItemsBase.itemName, classnames)); .where(inArray(ItemsBase.itemName, classnames));
for (const r of rows) have.add(r.itemName); for (const r of rows) have.add(normalizeClassname(r.itemName));
} }
const present = all.filter((e) => have.has(e.classname)).length; const present = all.filter((e) =>
have.has(normalizeClassname(e.classname)),
).length;
return { total: all.length, present, clonable: all.length - present }; return { total: all.length, present, clonable: all.length - present };
} }
@@ -561,7 +567,9 @@ export async function getClonableClassnames(
.select({ itemName: ItemsBase.itemName }) .select({ itemName: ItemsBase.itemName })
.from(ItemsBase) .from(ItemsBase)
.where(inArray(ItemsBase.itemName, classnames)); .where(inArray(ItemsBase.itemName, classnames));
for (const r of rows) have.add(r.itemName); for (const r of rows) have.add(normalizeClassname(r.itemName));
} }
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname); return all
.filter((e) => !have.has(normalizeClassname(e.classname)))
.map((e) => e.classname);
} }
+6 -3
View File
@@ -1,3 +1,4 @@
import { normalizeClassname } from "@/lib/furni/classname";
/** /**
* Furniture search & listing — powered by official Habbo furnidata. * Furniture search & listing — powered by official Habbo furnidata.
* *
@@ -37,7 +38,9 @@ export async function listFurniture(
const perPage = Math.min(options.perPage || 50, 100); const perPage = Math.min(options.perPage || 50, 100);
const page = options.page || 1; const page = options.page || 1;
const search = options.search?.toLowerCase().trim() || ""; const search = options.search?.toLowerCase().trim() || "";
const importedSet = options.importedSet; const importedSet = options.importedSet
? new Set([...options.importedSet].map(normalizeClassname))
: undefined;
try { try {
const map = await getOfficialHabboFurnidata(); const map = await getOfficialHabboFurnidata();
@@ -58,8 +61,8 @@ export async function listFurniture(
if (importedSet) { if (importedSet) {
// Non-imported first (id desc), then imported (id desc). // Non-imported first (id desc), then imported (id desc).
items.sort((a, b) => { items.sort((a, b) => {
const aImp = importedSet.has(a.classname) ? 1 : 0; const aImp = importedSet.has(normalizeClassname(a.classname)) ? 1 : 0;
const bImp = importedSet.has(b.classname) ? 1 : 0; const bImp = importedSet.has(normalizeClassname(b.classname)) ? 1 : 0;
if (aImp !== bImp) return aImp - bImp; if (aImp !== bImp) return aImp - bImp;
return b.id - a.id; return b.id - a.id;
}); });