diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index f60601c371..06681d8c23 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -4,6 +4,11 @@ import { eq, sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; 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 { logAudit } from "@/lib/services/audit"; 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); // ── 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"); if (sourceId) { const source = await getSource(sourceId); @@ -325,7 +335,7 @@ export const GET = withAdmin( ...(fd.roomitemtypes?.furnitype ?? []), ...(fd.wallitemtypes?.furnitype ?? []), ]) { - if (e.classname) furniDataSet.add(e.classname); + if (e.classname) furniDataSet.add(normalizeClassname(e.classname)); } } catch { /* best effort */ @@ -345,7 +355,13 @@ export const GET = withAdmin( } const enriched = items.map((item) => { - const imported = item.present; + const presence = localFurnitureStatus( + item.classname, + localIndex, + furniDataSet, + catalogIdSet, + ); + const imported = presence.alreadyImported; let nitroExists = false; if (imported) { const starIdx = item.classname.indexOf("*"); @@ -357,7 +373,17 @@ export const GET = withAdmin( /*turbopackIgnore: true*/ nitroDir, `${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 { id: item.id, @@ -367,10 +393,9 @@ export const GET = withAdmin( description: item.description, revision: 0, category: String(item.category ?? ""), - alreadyImported: imported, + ...presence, nitroExists, - hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined, - hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined, + iconUrl: sourceIconBase ? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png` : 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 const furniDataSet = new Set(); const catalogIdSet = new Set(); @@ -411,7 +426,7 @@ export const GET = withAdmin( ...(fd.roomitemtypes?.furnitype ?? []), ...(fd.wallitemtypes?.furnitype ?? []), ]) { - if (e.classname) furniDataSet.add(e.classname); + if (e.classname) furniDataSet.add(normalizeClassname(e.classname)); } } catch { /* best effort */ @@ -438,7 +453,13 @@ export const GET = withAdmin( }); 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; if (imported) { const starIdx = item.classname.indexOf("*"); @@ -450,14 +471,22 @@ export const GET = withAdmin( /*turbopackIgnore: true*/ nitroDir, `${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 { ...item, - alreadyImported: imported, + ...presence, nitroExists, - hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined, - hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined, }; }); diff --git a/src/app/api/admin/studio/inspect/route.test.ts b/src/app/api/admin/studio/inspect/route.test.ts index 35600f8ab8..a26f9488aa 100644 --- a/src/app/api/admin/studio/inspect/route.test.ts +++ b/src/app/api/admin/studio/inspect/route.test.ts @@ -94,4 +94,50 @@ describe("furniture inspection", () => { expect(response.status).toBe(200); 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); + }); }); diff --git a/src/app/api/admin/studio/inspect/route.ts b/src/app/api/admin/studio/inspect/route.ts index 37fdf5a356..f22b3c1d3c 100644 --- a/src/app/api/admin/studio/inspect/route.ts +++ b/src/app/api/admin/studio/inspect/route.ts @@ -4,6 +4,7 @@ import { sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; import { db } from "@/lib/db"; +import { normalizeClassname } from "@/lib/furni/classname"; import { type FurnitureInspection, validateClassnames, @@ -12,7 +13,10 @@ import { PERMS } from "@/lib/permission-slugs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; 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 { const stat = await fs.stat(path.join(directory, name)); 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. export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, @@ -32,17 +46,17 @@ export const POST = withAdmin( if (!names) return apiError("Provide 1–500 valid furniture classnames", 400); const namesSql = sql.join( - names.map((name) => sql`${name}`), + names.map((name) => sql`${normalizeClassname(name)}`), sql`, `, ); 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 [ Array, unknown, ]; 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 [ Array, unknown, @@ -74,8 +88,9 @@ export const POST = withAdmin( continue; } const name = String(entry.classname ?? ""); - if (!names.includes(name)) continue; - const values = entries.get(name) ?? []; + const key = normalizeClassname(name); + if (!names.some((value) => normalizeClassname(value) === key)) continue; + const values = entries.get(key) ?? []; values.push({ id: Number(entry.id), name: String(entry.name ?? ""), @@ -83,7 +98,7 @@ export const POST = withAdmin( revision: Number(entry.revision ?? 0), type, }); - entries.set(name, values); + entries.set(key, values); } } const dirs = await getFurniAssetDirs(); @@ -92,16 +107,39 @@ export const POST = withAdmin( items.push( ...(await Promise.all( 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([ - asset(dirs.nitroDir, `${base}.nitro`), - asset(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`), + assetCandidates( + dirs.nitroDir, + variants.map((name) => `${name.split("*")[0]}.nitro`), + ), + assetCandidates( + dirs.iconDir, + variants.map((name) => `${name.replaceAll("*", "_")}_icon.png`), + ), ]); return { classname, - sql: rows.filter((row) => row.classname === classname), - catalog: offers.filter((row) => row.classname === classname), - furnidata: entries.get(classname) ?? [], + sql: rows.filter( + (row) => + normalizeClassname(row.classname) === + normalizeClassname(classname), + ), + catalog: offers.filter( + (row) => + normalizeClassname(row.classname) === + normalizeClassname(classname), + ), + furnidata: entries.get(normalizeClassname(classname)) ?? [], furnidataReadable: readable, nitro, icon, diff --git a/src/lib/furni/local-presence.test.ts b/src/lib/furni/local-presence.test.ts new file mode 100644 index 0000000000..c8aede3f9f --- /dev/null +++ b/src/lib/furni/local-presence.test.ts @@ -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(); + }); +}); diff --git a/src/lib/furni/local-presence.ts b/src/lib/furni/local-presence.ts new file mode 100644 index 0000000000..ee9c81747e --- /dev/null +++ b/src/lib/furni/local-presence.ts @@ -0,0 +1,29 @@ +import { normalizeClassname } from "./classname"; + +export function indexLocalFurniture( + rows: T[], +) { + const index = new Map(); + 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>, + furnidata: Set, + catalogIds: Set, +) { + 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)), + }; +} diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index 37d7627a42..7b82ba89bb 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -2,6 +2,7 @@ import { promises as fs } from "node:fs"; import { eq, inArray, sql } from "drizzle-orm"; import { db, ItemsBase } from "@/lib/db"; 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 { logger } from "@/lib/logger"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; @@ -510,21 +511,24 @@ export async function getCloneList(params: { .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .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 = filter === "missing" - ? searched.filter((e) => !present.has(e.classname)) + ? searched.filter((e) => !present.has(normalizeClassname(e.classname))) : filter === "present" - ? searched.filter((e) => present.has(e.classname)) + ? searched.filter((e) => present.has(normalizeClassname(e.classname))) : searched; const total = filtered.length; const start = (Math.max(page, 1) - 1) * perPage; const slice = filtered.slice(start, start + perPage); return { - items: slice.map((e) => ({ ...e, present: present.has(e.classname) })), + items: slice.map((e) => ({ + ...e, + present: present.has(normalizeClassname(e.classname)), + })), meta: { page: Math.max(page, 1), perPage, total }, }; } @@ -540,9 +544,11 @@ export async function getCloneStats( .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .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 }; } @@ -561,7 +567,9 @@ export async function getClonableClassnames( .select({ itemName: ItemsBase.itemName }) .from(ItemsBase) .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); } diff --git a/src/lib/services/habbofurni.ts b/src/lib/services/habbofurni.ts index e57f80e0fa..ab6b6826aa 100644 --- a/src/lib/services/habbofurni.ts +++ b/src/lib/services/habbofurni.ts @@ -1,3 +1,4 @@ +import { normalizeClassname } from "@/lib/furni/classname"; /** * 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 page = options.page || 1; const search = options.search?.toLowerCase().trim() || ""; - const importedSet = options.importedSet; + const importedSet = options.importedSet + ? new Set([...options.importedSet].map(normalizeClassname)) + : undefined; try { const map = await getOfficialHabboFurnidata(); @@ -58,8 +61,8 @@ export async function listFurniture( if (importedSet) { // Non-imported first (id desc), then imported (id desc). items.sort((a, b) => { - const aImp = importedSet.has(a.classname) ? 1 : 0; - const bImp = importedSet.has(b.classname) ? 1 : 0; + const aImp = importedSet.has(normalizeClassname(a.classname)) ? 1 : 0; + const bImp = importedSet.has(normalizeClassname(b.classname)) ? 1 : 0; if (aImp !== bImp) return aImp - bImp; return b.id - a.id; });