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";
|
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
mode: z.literal("repair").optional(),
|
||||||
id: z.uuid().refine((value) => value[14] === "4"),
|
id: z.uuid().refine((value) => value[14] === "4"),
|
||||||
sourceId: z.string().max(100).optional(),
|
sourceId: z.string().max(100).optional(),
|
||||||
translate: z.boolean().default(false),
|
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 { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiError, apiOk } from "@/lib/api-response";
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
import { db } from "@/lib/db";
|
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||||
import { normalizeClassname } from "@/lib/furni/classname";
|
|
||||||
import {
|
|
||||||
type FurnitureInspection,
|
|
||||||
validateClassnames,
|
|
||||||
} from "@/lib/furni/studio-inspection";
|
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
import { inspectFurniture } from "@/lib/services/furniture-inspection";
|
||||||
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.
|
|
||||||
export const POST = withAdmin(
|
export const POST = withAdmin(
|
||||||
{ permission: PERMS.ASSETS_IMPORT },
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null),
|
||||||
const names = validateClassnames(body?.classnames);
|
names = validateClassnames(body?.classnames);
|
||||||
if (!names)
|
if (!names)
|
||||||
return apiError("Provide 1–500 valid furniture classnames", 400);
|
return apiError("Provide 1–500 valid furniture classnames", 400);
|
||||||
const namesSql = sql.join(
|
return apiOk({ items: await inspectFurniture(names) });
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -5,6 +5,7 @@ import { adminFetch } from "@/lib/admin-fetch";
|
|||||||
import {
|
import {
|
||||||
compareFurniture,
|
compareFurniture,
|
||||||
type FurnitureInspection,
|
type FurnitureInspection,
|
||||||
|
furnitureHealth,
|
||||||
} from "@/lib/furni/studio-inspection";
|
} from "@/lib/furni/studio-inspection";
|
||||||
import type { FurniItem } from "./studio-types";
|
import type { FurniItem } from "./studio-types";
|
||||||
|
|
||||||
@@ -172,7 +173,15 @@ export function FurnitureComparison({
|
|||||||
</div>
|
</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]);
|
const inspection = useFurnitureInspection([item.classname]);
|
||||||
if (inspection.loading)
|
if (inspection.loading)
|
||||||
return (
|
return (
|
||||||
@@ -192,8 +201,54 @@ export function FurnitureInspector({ item }: { item: FurniItem }) {
|
|||||||
const local = inspection.items.find(
|
const local = inspection.items.find(
|
||||||
(entry) => entry.classname === item.classname,
|
(entry) => entry.classname === item.classname,
|
||||||
);
|
);
|
||||||
return local ? (
|
const health = local ? furnitureHealth(local) : null;
|
||||||
<FurnitureComparison item={item} local={local} />
|
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>
|
<p>Local inspection unavailable.</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ export function useFurnitureJobs(onComplete: () => void) {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
const submit = async (
|
const submit = async (
|
||||||
items: ImportJobItem[],
|
items: ImportJobItem[],
|
||||||
options: { sourceId?: string; translate: boolean; langs?: string[] },
|
options: {
|
||||||
|
sourceId?: string;
|
||||||
|
translate: boolean;
|
||||||
|
langs?: string[];
|
||||||
|
mode?: "repair";
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
if (submitting.current) return false;
|
if (submitting.current) return false;
|
||||||
submitting.current = true;
|
submitting.current = true;
|
||||||
@@ -144,6 +149,7 @@ export function FurnitureJobHistory({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
void state.submit(failed, {
|
void state.submit(failed, {
|
||||||
sourceId: job.sourceId,
|
sourceId: job.sourceId,
|
||||||
|
mode: job.mode,
|
||||||
translate: job.translate,
|
translate: job.translate,
|
||||||
langs: job.langs,
|
langs: job.langs,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2082,8 +2082,26 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FurnitureInspector
|
<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}
|
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 */}
|
{/* Auto-catalog preview */}
|
||||||
{detailPreview && (
|
{detailPreview && (
|
||||||
@@ -2137,16 +2155,6 @@ export function StudioClient({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<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 && (
|
{!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)]">
|
<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;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
export interface ImportJob {
|
export interface ImportJob {
|
||||||
|
mode?: "repair";
|
||||||
id: string;
|
id: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -65,3 +65,39 @@ describe("studio inspection", () => {
|
|||||||
).toBe("conflict");
|
).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";
|
: "present";
|
||||||
return { state, fields } as const;
|
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;
|
skipFurniDataWrite?: boolean;
|
||||||
updateExisting?: boolean;
|
updateExisting?: boolean;
|
||||||
repairExisting?: boolean;
|
repairExisting?: boolean;
|
||||||
|
preserveAssets?: boolean;
|
||||||
/** Validated matching bundle supplied by a queued import. */
|
/** Validated matching bundle supplied by a queued import. */
|
||||||
providedNitro?: Buffer;
|
providedNitro?: Buffer;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
@@ -702,14 +703,14 @@ export async function importSingleFurni(params: {
|
|||||||
let iconFailure = "",
|
let iconFailure = "",
|
||||||
swfFailure = "";
|
swfFailure = "";
|
||||||
const [iconOkResult, swfOk] = await Promise.all([
|
const [iconOkResult, swfOk] = await Promise.all([
|
||||||
preservingExisting && existsSync(iconPath)
|
(preservingExisting || params.preserveAssets) && existsSync(iconPath)
|
||||||
? Promise.resolve(true)
|
? Promise.resolve(true)
|
||||||
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
|
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
|
||||||
iconFailure = detail;
|
iconFailure = detail;
|
||||||
}),
|
}),
|
||||||
params.providedNitro
|
params.providedNitro || (params.preserveAssets && existsSync(nitroPath))
|
||||||
? Promise.resolve(false)
|
? Promise.resolve(false)
|
||||||
: preservingExisting && existsSync(swfPath)
|
: (preservingExisting || params.preserveAssets) && existsSync(swfPath)
|
||||||
? Promise.resolve(true)
|
? Promise.resolve(true)
|
||||||
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
|
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
|
||||||
swfFailure = 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"}`);
|
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
|
||||||
|
|
||||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { readFurnitureAttachment } from "./furni-attachment";
|
|||||||
import { patchLocalizedFurniDataEntries } from "./furni-data-i18n";
|
import { patchLocalizedFurniDataEntries } from "./furni-data-i18n";
|
||||||
import { ensureDirectories, importSingleFurni } from "./furni-import";
|
import { ensureDirectories, importSingleFurni } from "./furni-import";
|
||||||
import { ImportJobStore } from "./furni-job-store";
|
import { ImportJobStore } from "./furni-job-store";
|
||||||
|
import { repairFurniture } from "./furniture-repair";
|
||||||
import { rcon } from "./rcon";
|
import { rcon } from "./rcon";
|
||||||
|
|
||||||
const LOCK = "furniture-import-worker:v1";
|
const LOCK = "furniture-import-worker:v1";
|
||||||
@@ -77,7 +78,9 @@ async function drain() {
|
|||||||
job.userId,
|
job.userId,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const result = await importSingleFurni({
|
const result = await (job.mode === "repair"
|
||||||
|
? repairFurniture
|
||||||
|
: importSingleFurni)({
|
||||||
...item,
|
...item,
|
||||||
repairExisting: true,
|
repairExisting: true,
|
||||||
providedNitro,
|
providedNitro,
|
||||||
@@ -100,7 +103,7 @@ async function drain() {
|
|||||||
repairExisting: true,
|
repairExisting: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (job.translate)
|
if (job.translate && job.mode !== "repair")
|
||||||
try {
|
try {
|
||||||
await patchLocalizedFurniDataEntries([item], true, job.langs);
|
await patchLocalizedFurniDataEntries([item], true, job.langs);
|
||||||
} catch {
|
} 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