feat(catalog): repair missing furniture components with verified status
CI / check (push) Successful in 1m33s
CI / deploy (push) Successful in 1m22s
CI / e2e (push) Successful in 24s

This commit is contained in:
Simo committed 2026-09-05 18:12:34 +02:00
1 parent 3c24df9b1d
commit 570f3dde44
13 files changed
+438 -164

No files matched your search

@@ -10,6 +10,7 @@ import { ImportJobStore } from "@/lib/services/furni-job-store";
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
const schema = z.object({
mode: z.literal("repair").optional(),
id: z.uuid().refine((value) => value[14] === "4"),
sourceId: z.string().max(100).optional(),
translate: z.boolean().default(false),
+5 -143
View File
@@ -1,153 +1,15 @@
import { promises as fs } from "node:fs";
import path from "node:path";
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,
} from "@/lib/furni/studio-inspection";
import { validateClassnames } from "@/lib/furni/studio-inspection";
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,
): Promise<{ exists: boolean | null; bytes: number }> {
try {
const stat = await fs.stat(path.join(directory, name));
return { exists: stat.isFile(), bytes: stat.size };
} catch (error) {
return {
exists: (error as NodeJS.ErrnoException).code === "ENOENT" ? false : null,
bytes: 0,
};
}
}
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.
import { inspectFurniture } from "@/lib/services/furniture-inspection";
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const body = await request.json().catch(() => null);
const names = validateClassnames(body?.classnames);
const body = await request.json().catch(() => null),
names = validateClassnames(body?.classnames);
if (!names)
return apiError("Provide 1–500 valid furniture classnames", 400);
const namesSql = sql.join(
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 LOWER(TRIM(item_name)) IN (${namesSql})`,
)) as unknown as [
Array<FurnitureInspection["sql"][number] & { classname: string }>,
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 LOWER(TRIM(ib.item_name)) IN (${namesSql})`,
)) as unknown as [
Array<FurnitureInspection["catalog"][number] & { classname: string }>,
unknown,
];
let readable = true;
let data: Record<string, unknown> = {};
try {
data = await readFurniData();
} catch {
readable = false;
}
const entries = new Map<string, FurnitureInspection["furnidata"]>();
for (const [section, type] of [
["roomitemtypes", "flooritem"],
["wallitemtypes", "wallitem"],
] as const) {
const list = (
data[section] as
| { furnitype?: Array<Record<string, unknown>> }
| undefined
)?.furnitype;
if (!Array.isArray(list)) {
readable = false;
continue;
}
for (const entry of list) {
if (!entry || typeof entry !== "object") {
readable = false;
continue;
}
const name = String(entry.classname ?? "");
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 ?? ""),
description: String(entry.description ?? ""),
revision: Number(entry.revision ?? 0),
type,
});
entries.set(key, values);
}
}
const dirs = await getFurniAssetDirs();
const items: FurnitureInspection[] = [];
for (let offset = 0; offset < names.length; offset += 20) {
items.push(
...(await Promise.all(
names.slice(offset, offset + 20).map(async (classname) => {
const variants = [
classname,
...rows
.filter(
(row) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
)
.map((row) => row.classname),
];
const [nitro, icon] = await Promise.all([
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) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
),
catalog: offers.filter(
(row) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
),
furnidata: entries.get(normalizeClassname(classname)) ?? [],
furnidataReadable: readable,
nitro,
icon,
};
}),
)),
);
}
return apiOk({ items });
return apiOk({ items: await inspectFurniture(names) });
},
);