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),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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