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) });
},
);
@@ -5,6 +5,7 @@ import { adminFetch } from "@/lib/admin-fetch";
import {
compareFurniture,
type FurnitureInspection,
furnitureHealth,
} from "@/lib/furni/studio-inspection";
import type { FurniItem } from "./studio-types";
@@ -172,7 +173,15 @@ export function FurnitureComparison({
</div>
);
}
export function FurnitureInspector({ item }: { item: FurniItem }) {
export function FurnitureInspector({
item,
onRepair,
busy = false,
}: {
item: FurniItem;
onRepair?: () => Promise<unknown>;
busy?: boolean;
}) {
const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading)
return (
@@ -192,8 +201,54 @@ export function FurnitureInspector({ item }: { item: FurniItem }) {
const local = inspection.items.find(
(entry) => entry.classname === item.classname,
);
return local ? (
<FurnitureComparison item={item} local={local} />
const health = local ? furnitureHealth(local) : null;
return local && health ? (
<div className="space-y-3">
<section className="rounded-md border border-[var(--admin-border)] p-3 space-y-2">
<strong>
{
{
complete: "Complete",
incomplete: "Needs repair",
new: "Not imported",
conflict: "Local conflict",
unknown: "Not verified",
}[health.state]
}
</strong>
{health.missing.length > 0 && (
<p className="text-sm">Missing: {health.missing.join(", ")}</p>
)}
{health.state === "conflict" && (
<p className="text-sm">
Duplicate records or mismatched IDs/types require review before
repair.
</p>
)}
{health.state === "unknown" && (
<p className="text-sm">
Some local data could not be checked. Retry verification before
repair.
</p>
)}
{health.state === "incomplete" && onRepair && (
<>
<p className="text-sm text-[var(--admin-text-muted)]">
Repair adds missing parts and preserves existing IDs, settings,
furnidata and catalog offers. Missing offers use the automatic
category and price shown below.
</p>
<Button disabled={busy} onClick={() => void onRepair()}>
{busy ? "Repair queued or running…" : "Repair furni"}
</Button>
</>
)}
<Button variant="outline" disabled={busy} onClick={inspection.retry}>
Recheck local state
</Button>
</section>
<FurnitureComparison item={item} local={local} />
</div>
) : (
<p>Local inspection unavailable.</p>
);
@@ -46,7 +46,12 @@ export function useFurnitureJobs(onComplete: () => void) {
}, [refresh]);
const submit = async (
items: ImportJobItem[],
options: { sourceId?: string; translate: boolean; langs?: string[] },
options: {
sourceId?: string;
translate: boolean;
langs?: string[];
mode?: "repair";
},
) => {
if (submitting.current) return false;
submitting.current = true;
@@ -144,6 +149,7 @@ export function FurnitureJobHistory({
onClick={() =>
void state.submit(failed, {
sourceId: job.sourceId,
mode: job.mode,
translate: job.translate,
langs: job.langs,
})
+19 -11
View File
@@ -2082,8 +2082,26 @@ export function StudioClient({
</div>
<FurnitureInspector
key={`${detail.classname}-${detail.alreadyImported}`}
key={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
.filter((j) => j.state === "completed")
.map((j) => j.id)
.join(",")}`}
item={detail}
busy={
jobs.busy ||
jobs.jobs.some(
(j) =>
(j.state === "queued" || j.state === "running") &&
j.items.some((i) => i.classname === detail.classname),
)
}
onRepair={() =>
jobs.submit([detail], {
sourceId: activeSource || undefined,
translate: false,
mode: "repair",
})
}
/>
{/* Auto-catalog preview */}
{detailPreview && (
@@ -2137,16 +2155,6 @@ export function StudioClient({
</Button>
) : (
<div className="space-y-2">
<Button
variant="outline"
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={() =>
setReview({ items: [detail], single: true })
}
>
Complete missing data
</Button>
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
+1
View File
@@ -9,6 +9,7 @@ export interface ImportJobItem {
attachmentId?: string;
}
export interface ImportJob {
mode?: "repair";
id: string;
userId: number;
createdAt: string;
+36
View File
@@ -65,3 +65,39 @@ describe("studio inspection", () => {
).toBe("conflict");
});
});
import { furnitureHealth } from "./studio-inspection";
it("does not call SQL-only furniture complete", () => {
expect(
furnitureHealth({
...empty,
sql: [{ id: 1, spriteId: 2, name: "chair", type: "s" }],
}),
).toMatchObject({
state: "incomplete",
missing: ["Nitro", "Icon", "Furnidata", "Catalog offer"],
});
});
it("keeps inaccessible assets unverified", () => {
expect(
furnitureHealth({ ...empty, nitro: { exists: null, bytes: 0 } }).state,
).toBe("unknown");
});
it("detects mismatched local sprite IDs", () => {
expect(
furnitureHealth({
...empty,
sql: [{ id: 1, spriteId: 2, name: "chair", type: "s" }],
furnidata: [
{
id: 3,
name: "chair",
description: "",
revision: 1,
type: "flooritem",
},
],
}).state,
).toBe("conflict");
});
+34
View File
@@ -68,3 +68,37 @@ export function compareFurniture(
: "present";
return { state, fields } as const;
}
export function furnitureHealth(local: FurnitureInspection) {
const missing: string[] = [];
if (local.nitro.exists === false) missing.push("Nitro");
if (local.icon.exists === false) missing.push("Icon");
if (local.furnidataReadable && !local.furnidata.length)
missing.push("Furnidata");
if (!local.sql.length) missing.push("SQL item");
if (!local.catalog.length) missing.push("Catalog offer");
const sql = local.sql[0],
fd = local.furnidata[0];
const conflict =
local.sql.length > 1 ||
local.furnidata.length > 1 ||
!!(
sql &&
fd &&
(sql.spriteId !== fd.id ||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
);
const unknown =
!local.furnidataReadable ||
local.nitro.exists === null ||
local.icon.exists === null;
const state = conflict
? "conflict"
: unknown
? "unknown"
: !missing.length
? "complete"
: missing.length === 5
? "new"
: "incomplete";
return { state, missing } as const;
}
+10 -4
View File
@@ -513,6 +513,7 @@ export async function importSingleFurni(params: {
skipFurniDataWrite?: boolean;
updateExisting?: boolean;
repairExisting?: boolean;
preserveAssets?: boolean;
/** Validated matching bundle supplied by a queued import. */
providedNitro?: Buffer;
onProgress?: (status: string) => void;
@@ -702,14 +703,14 @@ export async function importSingleFurni(params: {
let iconFailure = "",
swfFailure = "";
const [iconOkResult, swfOk] = await Promise.all([
preservingExisting && existsSync(iconPath)
(preservingExisting || params.preserveAssets) && existsSync(iconPath)
? Promise.resolve(true)
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
iconFailure = detail;
}),
params.providedNitro
params.providedNitro || (params.preserveAssets && existsSync(nitroPath))
? Promise.resolve(false)
: preservingExisting && existsSync(swfPath)
: (preservingExisting || params.preserveAssets) && existsSync(swfPath)
? Promise.resolve(true)
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
swfFailure = detail;
@@ -753,7 +754,12 @@ export async function importSingleFurni(params: {
);
}
if (!swfOk && !nitroDownloadOk && !params.providedNitro)
if (
!swfOk &&
!nitroDownloadOk &&
!params.providedNitro &&
!(params.preserveAssets && existsSync(nitroPath))
)
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
// ── Convert SWF to Nitro ──────────────────────────────────────────
+5 -2
View File
@@ -9,6 +9,7 @@ import { readFurnitureAttachment } from "./furni-attachment";
import { patchLocalizedFurniDataEntries } from "./furni-data-i18n";
import { ensureDirectories, importSingleFurni } from "./furni-import";
import { ImportJobStore } from "./furni-job-store";
import { repairFurniture } from "./furniture-repair";
import { rcon } from "./rcon";
const LOCK = "furniture-import-worker:v1";
@@ -77,7 +78,9 @@ async function drain() {
job.userId,
)
: undefined;
const result = await importSingleFurni({
const result = await (job.mode === "repair"
? repairFurniture
: importSingleFurni)({
...item,
repairExisting: true,
providedNitro,
@@ -100,7 +103,7 @@ async function drain() {
repairExisting: true,
},
});
if (job.translate)
if (job.translate && job.mode !== "repair")
try {
await patchLocalizedFurniDataEntries([item], true, job.langs);
} catch {
+145
View File
@@ -0,0 +1,145 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
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() ? (stat.size > 0 ? true : null) : false,
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]
);
}
export async function inspectFurniture(
names: string[],
): Promise<FurnitureInspection[]> {
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 items;
}
+73
View File
@@ -0,0 +1,73 @@
import { beforeEach, expect, it, vi } from "vitest";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
const mocks = vi.hoisted(() => ({ inspect: vi.fn(), import: vi.fn() }));
vi.mock("./furniture-inspection", () => ({ inspectFurniture: mocks.inspect }));
vi.mock("./furni-import", () => ({ importSingleFurni: mocks.import }));
import { repairFurniture } from "./furniture-repair";
const complete: FurnitureInspection = {
classname: "chair",
sql: [{ id: 9, spriteId: 10, name: "Local name", type: "s" }],
furnidata: [
{
id: 10,
name: "Local name",
description: "Keep",
revision: 1,
type: "flooritem",
},
],
furnidataReadable: true,
catalog: [{ id: 2, pageId: 3, credits: 5, points: 0 }],
nitro: { exists: true, bytes: 200 },
icon: { exists: true, bytes: 100 },
};
const params = {
classname: "chair",
id: 50,
name: "Source name",
description: "Source",
type: "flooritem",
revision: 2,
category: "other",
};
beforeEach(() => {
vi.resetAllMocks();
mocks.import.mockResolvedValue({ ok: true, itemId: 9, warnings: [] });
});
it("does nothing for complete furniture", async () => {
mocks.inspect.mockResolvedValue([complete]);
expect((await repairFurniture(params)).ok).toBe(true);
expect(mocks.import).not.toHaveBeenCalled();
});
it("preserves local IDs, existing furnidata and assets and verifies afterward", async () => {
mocks.inspect
.mockResolvedValueOnce([{ ...complete, icon: { exists: false, bytes: 0 } }])
.mockResolvedValueOnce([complete]);
expect((await repairFurniture(params)).ok).toBe(true);
expect(mocks.import).toHaveBeenCalledWith(
expect.objectContaining({
id: 10,
repairExisting: true,
preserveAssets: true,
skipFurniDataWrite: true,
}),
);
expect(mocks.inspect).toHaveBeenCalledTimes(2);
});
it("does not report success when a component is still missing", async () => {
mocks.inspect.mockResolvedValue([
{ ...complete, icon: { exists: false, bytes: 0 } },
]);
expect(await repairFurniture(params)).toMatchObject({
ok: false,
error: "Repair incomplete. Still missing: Icon",
});
});
it("blocks conflicting and unreadable local data before mutation", async () => {
mocks.inspect.mockResolvedValue([{ ...complete, furnidataReadable: false }]);
await expect(repairFurniture(params)).rejects.toThrow("blocked");
expect(mocks.import).not.toHaveBeenCalled();
});
+44
View File
@@ -0,0 +1,44 @@
import { furnitureHealth } from "@/lib/furni/studio-inspection";
import { importSingleFurni } from "./furni-import";
import { inspectFurniture } from "./furniture-inspection";
export async function repairFurniture(
params: Parameters<typeof importSingleFurni>[0],
): ReturnType<typeof importSingleFurni> {
const [before] = await inspectFurniture([params.classname]);
const health = furnitureHealth(before);
if (health.state === "conflict" || health.state === "unknown")
throw Error(
"Repair blocked: local data is conflicting or could not be verified",
);
if (health.state === "complete")
return {
ok: true,
itemId: before.sql[0].id,
warnings: ["Already complete; no changes made"],
};
const result = await importSingleFurni({
...params,
id: before.sql[0]?.spriteId ?? before.furnidata[0]?.id ?? params.id,
repairExisting: true,
preserveAssets: true,
skipFurniDataWrite: before.furnidata.length > 0,
});
if (!result.ok) return result;
const [after] = await inspectFurniture([params.classname]);
const verified = furnitureHealth(after);
if (verified.state !== "complete")
return {
...result,
ok: false,
error: verified.missing.length
? `Repair incomplete. Still missing: ${verified.missing.join(", ")}`
: "Repair completed with unresolved local conflicts or unreadable data",
};
return {
...result,
warnings: [
...result.warnings,
"Verified: SQL, furnidata, Nitro, icon and catalog offer are present",
],
};
}