feat(catalog): repair missing furniture components with verified status
This commit is contained in:
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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ImportJobItem {
|
||||
attachmentId?: string;
|
||||
}
|
||||
export interface ImportJob {
|
||||
mode?: "repair";
|
||||
id: string;
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user