Add Catalog Studio inspection and guided import review
This commit is contained in:
1 parent
2578bf6a09
commit
26f071117b
7 files changed
+961
-44
No files matched your search
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
execute: vi.fn(),
|
||||||
|
read: vi.fn(),
|
||||||
|
stat: vi.fn(),
|
||||||
|
guard: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/api-handler", () => ({
|
||||||
|
withAdmin: (options: unknown, handler: unknown) => {
|
||||||
|
mocks.guard(options);
|
||||||
|
return handler;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
|
||||||
|
vi.mock("@/lib/services/furni-data", () => ({ readFurniData: mocks.read }));
|
||||||
|
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||||
|
getFurniAssetDirs: async () => ({
|
||||||
|
nitroDir: "/assets/nitro",
|
||||||
|
iconDir: "/assets/icons",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("node:fs", () => ({ promises: { stat: mocks.stat } }));
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
const request = (classnames: unknown) =>
|
||||||
|
new NextRequest("http://localhost/api/admin/studio/inspect", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ classnames }),
|
||||||
|
});
|
||||||
|
describe("furniture inspection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.execute.mockReset().mockResolvedValue([[], []]);
|
||||||
|
mocks.read.mockReset().mockResolvedValue({
|
||||||
|
roomitemtypes: { furnitype: [] },
|
||||||
|
wallitemtypes: { furnitype: [] },
|
||||||
|
});
|
||||||
|
mocks.stat.mockReset().mockRejectedValue({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
it("requires asset import permission", () => {
|
||||||
|
expect(mocks.guard).toHaveBeenCalledWith({
|
||||||
|
permission: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("rejects path traversal before reading data", async () => {
|
||||||
|
expect((await POST(request(["../secret"]))).status).toBe(400);
|
||||||
|
expect(mocks.execute).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.stat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("uses local SQL IDs and color-aware asset filenames", async () => {
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 90,
|
||||||
|
spriteId: 91,
|
||||||
|
classname: "chair*2",
|
||||||
|
name: "Chair",
|
||||||
|
type: "s",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
[{ classname: "chair*2", id: 5, pageId: 7, credits: 3, points: 0 }],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
|
const response = await POST(request(["chair*2"]));
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.items[0].sql[0].id).toBe(90);
|
||||||
|
expect(body.items[0].catalog[0].pageId).toBe(7);
|
||||||
|
expect(
|
||||||
|
mocks.stat.mock.calls.map((call) =>
|
||||||
|
String(call[0]).replaceAll("\\", "/"),
|
||||||
|
),
|
||||||
|
).toEqual(["/assets/nitro/chair.nitro", "/assets/icons/chair_2_icon.png"]);
|
||||||
|
});
|
||||||
|
it("distinguishes unreadable data and denied assets from missing files", async () => {
|
||||||
|
mocks.read.mockRejectedValue(new Error("unreadable"));
|
||||||
|
mocks.stat.mockRejectedValue({ code: "EACCES" });
|
||||||
|
const body = await (await POST(request(["chair"]))).json();
|
||||||
|
expect(body.items[0].furnidataReadable).toBe(false);
|
||||||
|
expect(body.items[0].icon.exists).toBeNull();
|
||||||
|
expect(body.items[0].nitro.exists).toBeNull();
|
||||||
|
});
|
||||||
|
it("handles malformed furnidata without inventing a clean comparison", async () => {
|
||||||
|
mocks.read.mockResolvedValue({
|
||||||
|
roomitemtypes: { furnitype: {} },
|
||||||
|
wallitemtypes: { furnitype: [null] },
|
||||||
|
});
|
||||||
|
const response = await POST(request(["chair"]));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await response.json()).items[0].furnidataReadable).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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 {
|
||||||
|
type FurnitureInspection,
|
||||||
|
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) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Read-only inspection lives outside import routes so it cannot enqueue a Git export.
|
||||||
|
export const POST = withAdmin(
|
||||||
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
|
async (request) => {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const names = validateClassnames(body?.classnames);
|
||||||
|
if (!names)
|
||||||
|
return apiError("Provide 1–500 valid furniture classnames", 400);
|
||||||
|
const namesSql = sql.join(
|
||||||
|
names.map((name) => sql`${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 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 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 ?? "");
|
||||||
|
if (!names.includes(name)) continue;
|
||||||
|
const values = entries.get(name) ?? [];
|
||||||
|
values.push({
|
||||||
|
id: Number(entry.id),
|
||||||
|
name: String(entry.name ?? ""),
|
||||||
|
description: String(entry.description ?? ""),
|
||||||
|
revision: Number(entry.revision ?? 0),
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
entries.set(name, 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 base = classname.split("*")[0];
|
||||||
|
const [nitro, icon] = await Promise.all([
|
||||||
|
asset(dirs.nitroDir, `${base}.nitro`),
|
||||||
|
asset(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
classname,
|
||||||
|
sql: rows.filter((row) => row.classname === classname),
|
||||||
|
catalog: offers.filter((row) => row.classname === classname),
|
||||||
|
furnidata: entries.get(classname) ?? [],
|
||||||
|
furnidataReadable: readable,
|
||||||
|
nitro,
|
||||||
|
icon,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return apiOk({ items });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
import {
|
||||||
|
compareFurniture,
|
||||||
|
type FurnitureInspection,
|
||||||
|
} from "@/lib/furni/studio-inspection";
|
||||||
|
import type { FurniItem } from "./studio-types";
|
||||||
|
|
||||||
|
export function useFurnitureInspection(classnames: string[]) {
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const key = JSON.stringify({ classnames, attempt });
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
key: string;
|
||||||
|
items: FurnitureInspection[];
|
||||||
|
error: string;
|
||||||
|
loading: boolean;
|
||||||
|
}>({ key: "", items: [], error: "", loading: true });
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
setState({ key, items: [], error: "", loading: true });
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const names = JSON.parse(key).classnames as string[];
|
||||||
|
const inspected: FurnitureInspection[] = [];
|
||||||
|
for (let offset = 0; offset < names.length; offset += 500) {
|
||||||
|
const res = await adminFetch("/api/admin/studio/inspect", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classnames: names.slice(offset, offset + 500),
|
||||||
|
}),
|
||||||
|
signal: abort.signal,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !Array.isArray(data.items))
|
||||||
|
throw new Error(data.error || "Could not inspect local furniture");
|
||||||
|
inspected.push(...data.items);
|
||||||
|
}
|
||||||
|
if (!abort.signal.aborted)
|
||||||
|
setState({ key, items: inspected, error: "", loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
if (!abort.signal.aborted)
|
||||||
|
setState({
|
||||||
|
key,
|
||||||
|
items: [],
|
||||||
|
error: (error as Error).message,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [key]);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: state.key !== key || state.loading,
|
||||||
|
retry: () => setAttempt((value) => value + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const status = (value: boolean | null | undefined) =>
|
||||||
|
value == null ? "Not verified" : value ? "Present" : "Missing";
|
||||||
|
export function FurnitureComparison({
|
||||||
|
item,
|
||||||
|
local,
|
||||||
|
}: {
|
||||||
|
item: FurniItem;
|
||||||
|
local: FurnitureInspection;
|
||||||
|
}) {
|
||||||
|
const comparison = compareFurniture(item, local);
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<dl className="grid grid-cols-2 gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[".nitro", local.nitro.exists],
|
||||||
|
["Local icon", local.icon.exists],
|
||||||
|
[
|
||||||
|
"Furnidata",
|
||||||
|
local.furnidataReadable ? local.furnidata.length > 0 : null,
|
||||||
|
],
|
||||||
|
["SQL item", local.sql.length > 0],
|
||||||
|
["Catalog offer", local.catalog.length > 0],
|
||||||
|
] as const
|
||||||
|
).map(([label, present]) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-md border border-[var(--admin-border)] p-2"
|
||||||
|
>
|
||||||
|
<dt className="text-xs text-[var(--admin-text-muted)]">{label}</dt>
|
||||||
|
<dd
|
||||||
|
className={
|
||||||
|
present
|
||||||
|
? "text-[var(--admin-success)]"
|
||||||
|
: "text-[var(--admin-warning)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{status(present)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{comparison.state === "conflict" && (
|
||||||
|
<p role="alert" className="text-[var(--admin-warning)]">
|
||||||
|
Multiple local records use this classname. Review them in Catalog
|
||||||
|
audit before importing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!local.furnidataReadable && (
|
||||||
|
<p className="text-[var(--admin-warning)]">
|
||||||
|
Local furnidata could not be read. Its values are not verified.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left">Field</th>
|
||||||
|
<th className="p-2 text-left">Local now</th>
|
||||||
|
<th className="p-2 text-left">Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{comparison.fields.map((field) => (
|
||||||
|
<tr
|
||||||
|
key={field.label}
|
||||||
|
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""}
|
||||||
|
>
|
||||||
|
<th className="p-2 text-left">
|
||||||
|
{field.label}
|
||||||
|
{field.changed ? " · Changed" : ""}
|
||||||
|
</th>
|
||||||
|
<td className="max-w-48 break-words p-2">
|
||||||
|
{field.before ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-48 break-words p-2">
|
||||||
|
{field.after ?? "Not supplied"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Source values are compared with local data. Import may remap IDs and
|
||||||
|
translate names; existing SQL items are skipped by this import flow.
|
||||||
|
</p>
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer font-medium">
|
||||||
|
SQL records and catalog offers
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 text-xs">
|
||||||
|
{local.sql.length ? (
|
||||||
|
local.sql.map((row) => (
|
||||||
|
<p key={row.id}>
|
||||||
|
Item #{row.id} · Sprite #{row.spriteId} · {row.name}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>No local SQL item.</p>
|
||||||
|
)}
|
||||||
|
{local.catalog.map((row) => (
|
||||||
|
<p key={row.id}>
|
||||||
|
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "}
|
||||||
|
{row.points} points
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function FurnitureInspector({ item }: { item: FurniItem }) {
|
||||||
|
const inspection = useFurnitureInspection([item.classname]);
|
||||||
|
if (inspection.loading)
|
||||||
|
return (
|
||||||
|
<p role="status" className="py-3 text-sm">
|
||||||
|
Checking local data and assets…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (inspection.error)
|
||||||
|
return (
|
||||||
|
<div role="alert">
|
||||||
|
<p>{inspection.error}</p>
|
||||||
|
<Button variant="outline" onClick={inspection.retry}>
|
||||||
|
Retry inspection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const local = inspection.items.find(
|
||||||
|
(entry) => entry.classname === item.classname,
|
||||||
|
);
|
||||||
|
return local ? (
|
||||||
|
<FurnitureComparison item={item} local={local} />
|
||||||
|
) : (
|
||||||
|
<p>Local inspection unavailable.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
||||||
|
import { compareFurniture } from "@/lib/furni/studio-inspection";
|
||||||
|
import {
|
||||||
|
FurnitureComparison,
|
||||||
|
useFurnitureInspection,
|
||||||
|
} from "./furniture-inspector";
|
||||||
|
import type { FurniItem } from "./studio-types";
|
||||||
|
|
||||||
|
export function ImportReview({
|
||||||
|
items,
|
||||||
|
source,
|
||||||
|
translation,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
items: FurniItem[];
|
||||||
|
source: string;
|
||||||
|
translation: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (items: FurniItem[]) => void;
|
||||||
|
}) {
|
||||||
|
const inspection = useFurnitureInspection(
|
||||||
|
items.map((item) => item.classname),
|
||||||
|
);
|
||||||
|
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
|
||||||
|
const [step, setStep] = useState<"review" | "confirm">("review");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const localByName = new Map(
|
||||||
|
inspection.items.map((item) => [item.classname, item]),
|
||||||
|
);
|
||||||
|
const complete = items.every((item) => localByName.has(item.classname));
|
||||||
|
const ready = items.filter((item) => {
|
||||||
|
const local = localByName.get(item.classname);
|
||||||
|
return (
|
||||||
|
local &&
|
||||||
|
local.sql.length === 0 &&
|
||||||
|
compareFurniture(item, local).state !== "conflict"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const blocked = items.filter((item) => {
|
||||||
|
const local = localByName.get(item.classname);
|
||||||
|
return local && compareFurniture(item, local).state === "conflict";
|
||||||
|
}).length;
|
||||||
|
const existing = items.filter((item) => {
|
||||||
|
const local = localByName.get(item.classname);
|
||||||
|
return (
|
||||||
|
local &&
|
||||||
|
local.sql.length > 0 &&
|
||||||
|
compareFurniture(item, local).state !== "conflict"
|
||||||
|
);
|
||||||
|
}).length;
|
||||||
|
const pageItems = (step === "review" ? items : ready).slice(
|
||||||
|
page * 50,
|
||||||
|
(page + 1) * 50,
|
||||||
|
);
|
||||||
|
const pageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((step === "review" ? items : ready).length / 50),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex max-h-[90dvh] w-[calc(100vw-2rem)] max-w-3xl sm:max-w-3xl flex-col overflow-hidden border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === "review"
|
||||||
|
? "Review furniture import"
|
||||||
|
: "Confirm furniture import"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
1. Select → 2. Review → 3. Import
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||||
|
<div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>Source:</strong> {source}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Destination:</strong> local catalog, furnidata, .nitro and
|
||||||
|
icons
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Translation:</strong> {translation}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Selection:</strong> {items.length} furniture items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{inspection.loading ? (
|
||||||
|
<p role="status">Checking local data and assets…</p>
|
||||||
|
) : inspection.error ? (
|
||||||
|
<div role="alert">
|
||||||
|
<p>{inspection.error}</p>
|
||||||
|
<Button variant="outline" onClick={inspection.retry}>
|
||||||
|
Retry inspection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !complete ? (
|
||||||
|
<p role="alert">
|
||||||
|
Inspection is incomplete. Close and retry before importing.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p role="status" className="text-sm">
|
||||||
|
{ready.length} ready to import · {existing} already in SQL
|
||||||
|
(skipped) · {blocked} conflicts (skipped)
|
||||||
|
</p>
|
||||||
|
{inspection.items.some(
|
||||||
|
(item) =>
|
||||||
|
!item.furnidataReadable ||
|
||||||
|
item.icon.exists === null ||
|
||||||
|
item.nitro.exists === null,
|
||||||
|
) && (
|
||||||
|
<p className="text-sm text-[var(--admin-warning)]">
|
||||||
|
Some local files could not be verified. Review the item
|
||||||
|
details before continuing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{step === "review" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pageItems.map((item) => {
|
||||||
|
const local = localByName.get(item.classname);
|
||||||
|
if (!local) return null;
|
||||||
|
const comparison = compareFurniture(item, local);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.classname}
|
||||||
|
className="rounded-lg border border-[var(--admin-border)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded === item.classname}
|
||||||
|
onClick={() =>
|
||||||
|
setExpanded(
|
||||||
|
expanded === item.classname ? "" : item.classname,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex min-h-11 w-full items-center justify-between gap-3 p-3 text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 break-words">
|
||||||
|
{item.name}
|
||||||
|
<span className="block text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{item.classname}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{comparison.state === "new"
|
||||||
|
? "New"
|
||||||
|
: comparison.state === "changed"
|
||||||
|
? "Differences"
|
||||||
|
: comparison.state === "conflict"
|
||||||
|
? "Conflict"
|
||||||
|
: comparison.state === "unknown"
|
||||||
|
? "Not fully verified"
|
||||||
|
: "Present"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded === item.classname && (
|
||||||
|
<div className="border-t border-[var(--admin-border)] p-3">
|
||||||
|
<FurnitureComparison item={item} local={local} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
Only the {ready.length} eligible items below will be
|
||||||
|
submitted. Existing SQL items and conflicts are excluded.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Automatic categories and prices below are estimates. Source
|
||||||
|
rules may override them; final results appear in the import
|
||||||
|
summary.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{pageItems.map((item) => {
|
||||||
|
const placement = previewAutoCatalog(
|
||||||
|
item.classname,
|
||||||
|
item.type,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.classname}
|
||||||
|
className="rounded-md border border-[var(--admin-border)] p-2 text-sm"
|
||||||
|
>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<p>
|
||||||
|
{placement.categoryLabel} · {placement.credits}{" "}
|
||||||
|
credits + {placement.points} points
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!inspection.loading && !inspection.error && pageCount > 1 && (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((value) => value - 1)}
|
||||||
|
>
|
||||||
|
Previous items
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
Page {page + 1} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={page + 1 === pageCount}
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
>
|
||||||
|
Next items
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={
|
||||||
|
step === "confirm"
|
||||||
|
? () => {
|
||||||
|
setStep("review");
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
: onCancel
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step === "confirm" ? "Back to review" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--admin-accent)",
|
||||||
|
color: "var(--admin-accent-foreground)",
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
inspection.loading ||
|
||||||
|
!!inspection.error ||
|
||||||
|
!complete ||
|
||||||
|
ready.length === 0
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (step === "review") {
|
||||||
|
setStep("confirm");
|
||||||
|
setPage(0);
|
||||||
|
} else onConfirm(ready);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === "review"
|
||||||
|
? "Continue to confirmation"
|
||||||
|
: `Start import (${ready.length})`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ import type { TreeNode } from "@/types/catalog";
|
|||||||
import { BatchProgress } from "./batch-progress";
|
import { BatchProgress } from "./batch-progress";
|
||||||
import { CatalogRail } from "./catalog-rail";
|
import { CatalogRail } from "./catalog-rail";
|
||||||
import { CheckboxDot } from "./checkbox-dot";
|
import { CheckboxDot } from "./checkbox-dot";
|
||||||
|
import { FurnitureInspector } from "./furniture-inspector";
|
||||||
|
import { ImportReview } from "./import-review";
|
||||||
import {
|
import {
|
||||||
getFurniImageUrl,
|
getFurniImageUrl,
|
||||||
handleImgError,
|
handleImgError,
|
||||||
@@ -108,6 +110,12 @@ export function StudioClient({
|
|||||||
initialTree: TreeNode[];
|
initialTree: TreeNode[];
|
||||||
defaultTranslate: boolean;
|
defaultTranslate: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [review, setReview] = useState<{
|
||||||
|
items: FurniItem[];
|
||||||
|
single: boolean;
|
||||||
|
cloneAll?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [preparingReview, setPreparingReview] = useState(false);
|
||||||
// Catalog tree
|
// Catalog tree
|
||||||
const [tree] = useState<TreeNode[]>(initialTree);
|
const [tree] = useState<TreeNode[]>(initialTree);
|
||||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||||||
@@ -389,44 +397,62 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function cloneAllMissing() {
|
async function prepareCloneReview() {
|
||||||
if (!activeSource) return;
|
if (!activeSource || preparingReview) return;
|
||||||
|
setPreparingReview(true);
|
||||||
// Fetch all clonable classnames
|
|
||||||
let classnames: string[] = [];
|
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch(
|
const response = await adminFetch(
|
||||||
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
|
"/api/admin/import/clone?source=" +
|
||||||
|
encodeURIComponent(activeSource) +
|
||||||
|
"&action=clonable",
|
||||||
);
|
);
|
||||||
const data = (await res.json()) as {
|
const result = await response.json();
|
||||||
classnames?: string[];
|
if (!response.ok || !Array.isArray(result.classnames))
|
||||||
error?: string;
|
throw new Error(result.error || "Could not load missing furniture");
|
||||||
};
|
const missing = new Set<string>(result.classnames);
|
||||||
if (!res.ok) {
|
if (!missing.size) {
|
||||||
toast.error(data.error || "Failed to fetch clonable items");
|
toast.info("All items from this source are already imported");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
classnames = data.classnames || [];
|
const collected = new Map<string, FurniItem>();
|
||||||
} catch {
|
let page = 1,
|
||||||
toast.error("Network error fetching clonable items");
|
lastPage = 1;
|
||||||
return;
|
do {
|
||||||
|
const res = await adminFetch(
|
||||||
|
"/api/admin/import/furni?source=" +
|
||||||
|
encodeURIComponent(activeSource) +
|
||||||
|
"&page=" +
|
||||||
|
page,
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!Array.isArray(data.items) ||
|
||||||
|
!Number.isFinite(data.meta?.lastPage)
|
||||||
|
)
|
||||||
|
throw new Error(data.error || "Could not prepare the full selection");
|
||||||
|
for (const item of data.items as FurniItem[])
|
||||||
|
if (missing.has(item.classname)) collected.set(item.classname, item);
|
||||||
|
lastPage = data.meta.lastPage;
|
||||||
|
page++;
|
||||||
|
} while (page <= lastPage && collected.size < missing.size);
|
||||||
|
if (collected.size !== missing.size)
|
||||||
|
throw new Error(
|
||||||
|
"The source changed while preparing the selection. Refresh and retry.",
|
||||||
|
);
|
||||||
|
setReview({
|
||||||
|
items: [...collected.values()],
|
||||||
|
single: false,
|
||||||
|
cloneAll: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setPreparingReview(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (classnames.length === 0) {
|
async function cloneAllMissing(classnames: string[]) {
|
||||||
toast.info("All items from this source are already imported");
|
if (!activeSource || !classnames.length) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceName =
|
|
||||||
sources.find((s) => s.id === activeSource)?.name || activeSource;
|
|
||||||
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: "Import all missing",
|
|
||||||
description: `${classnames.length} items to import from "${sourceName}"? This may take a while.`,
|
|
||||||
confirmLabel: "Import all missing",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
// Initialize progress tracking as a Map for BatchProgress compatibility
|
// Initialize progress tracking as a Map for BatchProgress compatibility
|
||||||
const initial = new Map<string, BatchItemStatus>();
|
const initial = new Map<string, BatchItemStatus>();
|
||||||
for (const cn of classnames) {
|
for (const cn of classnames) {
|
||||||
@@ -639,6 +665,7 @@ export function StudioClient({
|
|||||||
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (review) return;
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
|
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
|
||||||
@@ -656,7 +683,7 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [toggleAll]);
|
}, [toggleAll, review]);
|
||||||
|
|
||||||
async function importSingle(item: FurniItem) {
|
async function importSingle(item: FurniItem) {
|
||||||
setImportingId(item.classname);
|
setImportingId(item.classname);
|
||||||
@@ -711,12 +738,18 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBatch(requested?: string[]) {
|
async function importBatch(
|
||||||
const toImport = (requested ? items : filteredItems).filter(
|
requested?: string[],
|
||||||
|
reviewedItems?: FurniItem[],
|
||||||
|
) {
|
||||||
|
const toImport = (
|
||||||
|
reviewedItems ?? (requested ? items : filteredItems)
|
||||||
|
).filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
(requested
|
(requested
|
||||||
? requested.includes(i.classname)
|
? requested.includes(i.classname)
|
||||||
: selected.has(i.classname)) && !i.alreadyImported,
|
: selected.has(i.classname)) &&
|
||||||
|
(!!reviewedItems || !i.alreadyImported),
|
||||||
);
|
);
|
||||||
if (toImport.length === 0) {
|
if (toImport.length === 0) {
|
||||||
toast.info("Select furni that isn't imported yet");
|
toast.info("Select furni that isn't imported yet");
|
||||||
@@ -1292,7 +1325,11 @@ export function StudioClient({
|
|||||||
Catalog
|
Catalog
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Select value={activeSource} onValueChange={changeSource}>
|
<Select
|
||||||
|
value={activeSource}
|
||||||
|
onValueChange={changeSource}
|
||||||
|
disabled={preparingReview}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
aria-label="Furniture source"
|
aria-label="Furniture source"
|
||||||
className="h-8 w-44 text-xs"
|
className="h-8 w-44 text-xs"
|
||||||
@@ -1638,9 +1675,16 @@ export function StudioClient({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
||||||
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={prepareCloneReview}
|
||||||
|
disabled={preparingReview}
|
||||||
|
>
|
||||||
<CloudDownload size={14} />
|
<CloudDownload size={14} />
|
||||||
Import all missing ({cloneStats.clonable})
|
{preparingReview
|
||||||
|
? "Preparing review…"
|
||||||
|
: `Import all missing (${cloneStats.clonable})`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1649,7 +1693,14 @@ export function StudioClient({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
disabled={!!batchProgress}
|
disabled={!!batchProgress}
|
||||||
onClick={() => importBatch()}
|
onClick={() =>
|
||||||
|
setReview({
|
||||||
|
items: filteredItems.filter((item) =>
|
||||||
|
selected.has(item.classname),
|
||||||
|
),
|
||||||
|
single: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{batchProgress ? (
|
{batchProgress ? (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
@@ -2035,7 +2086,12 @@ export function StudioClient({
|
|||||||
{!item.alreadyImported && (
|
{!item.alreadyImported && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => importSingle(item)}
|
onClick={() =>
|
||||||
|
setReview({
|
||||||
|
items: [item],
|
||||||
|
single: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={importingId !== null}
|
disabled={importingId !== null}
|
||||||
aria-label={`Import ${item.classname}`}
|
aria-label={`Import ${item.classname}`}
|
||||||
className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
|
className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
|
||||||
@@ -2149,13 +2205,14 @@ export function StudioClient({
|
|||||||
|
|
||||||
{/* ── Detail drawer ─────────────────────────────── */}
|
{/* ── Detail drawer ─────────────────────────────── */}
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
|
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
|
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
Furni detail
|
Furni detail
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Close furniture details"
|
||||||
onClick={() => setDetail(null)}
|
onClick={() => setDetail(null)}
|
||||||
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||||
>
|
>
|
||||||
@@ -2210,6 +2267,10 @@ export function StudioClient({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FurnitureInspector
|
||||||
|
key={`${detail.classname}-${detail.alreadyImported}`}
|
||||||
|
item={detail}
|
||||||
|
/>
|
||||||
{/* Auto-catalog preview */}
|
{/* Auto-catalog preview */}
|
||||||
{detailPreview && (
|
{detailPreview && (
|
||||||
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
|
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
|
||||||
@@ -2251,7 +2312,7 @@ export function StudioClient({
|
|||||||
<Button
|
<Button
|
||||||
className="w-full gap-1.5"
|
className="w-full gap-1.5"
|
||||||
disabled={importingId !== null}
|
disabled={importingId !== null}
|
||||||
onClick={() => importSingle(detail)}
|
onClick={() => setReview({ items: [detail], single: true })}
|
||||||
>
|
>
|
||||||
{importingId === detail.classname ? (
|
{importingId === detail.classname ? (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
@@ -2313,6 +2374,35 @@ export function StudioClient({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{review && (
|
||||||
|
<ImportReview
|
||||||
|
items={review.items}
|
||||||
|
source={
|
||||||
|
sources.find((entry) => entry.id === activeSource)?.name ??
|
||||||
|
source.label
|
||||||
|
}
|
||||||
|
translation={
|
||||||
|
translate
|
||||||
|
? translateLangs.length
|
||||||
|
? translateLangs.join(", ")
|
||||||
|
: "Default languages"
|
||||||
|
: "Off"
|
||||||
|
}
|
||||||
|
onCancel={() => setReview(null)}
|
||||||
|
onConfirm={(accepted) => {
|
||||||
|
const single = review.single;
|
||||||
|
setReview(null);
|
||||||
|
if (review.cloneAll)
|
||||||
|
void cloneAllMissing(accepted.map((item) => item.classname));
|
||||||
|
else if (single && accepted[0]) void importSingle(accepted[0]);
|
||||||
|
else
|
||||||
|
void importBatch(
|
||||||
|
accepted.map((item) => item.classname),
|
||||||
|
accepted,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<NitroEditorDialog
|
<NitroEditorDialog
|
||||||
classname={editingClassname ?? ""}
|
classname={editingClassname ?? ""}
|
||||||
name={detail?.name ?? ""}
|
name={detail?.name ?? ""}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
compareFurniture,
|
||||||
|
type FurnitureInspection,
|
||||||
|
validateClassnames,
|
||||||
|
} from "./studio-inspection";
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
name: "Chair",
|
||||||
|
description: "A chair",
|
||||||
|
revision: 2,
|
||||||
|
type: "flooritem",
|
||||||
|
};
|
||||||
|
const empty: FurnitureInspection = {
|
||||||
|
classname: "chair",
|
||||||
|
sql: [],
|
||||||
|
catalog: [],
|
||||||
|
furnidata: [],
|
||||||
|
furnidataReadable: true,
|
||||||
|
nitro: { exists: false, bytes: 0 },
|
||||||
|
icon: { exists: false, bytes: 0 },
|
||||||
|
};
|
||||||
|
describe("studio inspection", () => {
|
||||||
|
it("rejects paths and oversized batches", () => {
|
||||||
|
expect(validateClassnames(["../file"])).toBeNull();
|
||||||
|
expect(validateClassnames(["a\\b"])).toBeNull();
|
||||||
|
expect(validateClassnames(Array(501).fill("chair"))).toBeNull();
|
||||||
|
expect(validateClassnames(["chair*0", "chair*0"])).toEqual(["chair*0"]);
|
||||||
|
});
|
||||||
|
it("identifies new furniture only after a successful lookup", () => {
|
||||||
|
expect(compareFurniture(source, empty).state).toBe("new");
|
||||||
|
expect(
|
||||||
|
compareFurniture(source, { ...empty, furnidataReadable: false }).state,
|
||||||
|
).toBe("unknown");
|
||||||
|
});
|
||||||
|
it("compares values without treating allocated IDs as conflicts", () => {
|
||||||
|
const local = {
|
||||||
|
...empty,
|
||||||
|
sql: [{ id: 901, spriteId: 902, name: "Old chair", type: "s" }],
|
||||||
|
furnidata: [
|
||||||
|
{
|
||||||
|
id: 902,
|
||||||
|
name: "Old chair",
|
||||||
|
description: "Old",
|
||||||
|
revision: 1,
|
||||||
|
type: "flooritem",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(compareFurniture(source, local).state).toBe("changed");
|
||||||
|
expect(
|
||||||
|
compareFurniture(source, local).fields.find((f) => f.label === "Name")
|
||||||
|
?.changed,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it("flags duplicate SQL classnames as conflicts", () => {
|
||||||
|
expect(
|
||||||
|
compareFurniture(source, {
|
||||||
|
...empty,
|
||||||
|
sql: [
|
||||||
|
{ id: 1, spriteId: 1, name: "Chair", type: "s" },
|
||||||
|
{ id: 2, spriteId: 2, name: "Chair", type: "s" },
|
||||||
|
],
|
||||||
|
}).state,
|
||||||
|
).toBe("conflict");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export interface FurnitureInspection {
|
||||||
|
classname: string;
|
||||||
|
sql: Array<{ id: number; spriteId: number; name: string; type: string }>;
|
||||||
|
catalog: Array<{
|
||||||
|
id: number;
|
||||||
|
pageId: number;
|
||||||
|
credits: number;
|
||||||
|
points: number;
|
||||||
|
}>;
|
||||||
|
furnidata: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
revision: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
furnidataReadable: boolean;
|
||||||
|
nitro: { exists: boolean | null; bytes: number };
|
||||||
|
icon: { exists: boolean | null; bytes: number };
|
||||||
|
}
|
||||||
|
export function validateClassnames(value: unknown): string[] | null {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
!value.length ||
|
||||||
|
value.length > 500 ||
|
||||||
|
value.some(
|
||||||
|
(v) =>
|
||||||
|
typeof v !== "string" || v.length > 200 || !/^[a-zA-Z0-9_*-]+$/.test(v),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return [...new Set(value)];
|
||||||
|
}
|
||||||
|
export function compareFurniture(
|
||||||
|
source: { name: string; description: string; revision: number; type: string },
|
||||||
|
local: FurnitureInspection,
|
||||||
|
) {
|
||||||
|
const fd = local.furnidata[0],
|
||||||
|
sql = local.sql[0];
|
||||||
|
const values = [
|
||||||
|
["Name", sql?.name ?? fd?.name, source.name],
|
||||||
|
["Description", fd?.description, source.description],
|
||||||
|
["Revision", fd?.revision, source.revision || undefined],
|
||||||
|
[
|
||||||
|
"Type",
|
||||||
|
sql ? (sql.type === "i" ? "wallitem" : "flooritem") : fd?.type,
|
||||||
|
source.type,
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
const fields = values.map(([label, before, after]) => ({
|
||||||
|
label,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
changed:
|
||||||
|
before !== undefined &&
|
||||||
|
after !== undefined &&
|
||||||
|
String(before) !== String(after),
|
||||||
|
}));
|
||||||
|
const state =
|
||||||
|
local.sql.length > 1 || local.furnidata.length > 1
|
||||||
|
? "conflict"
|
||||||
|
: !local.furnidataReadable
|
||||||
|
? "unknown"
|
||||||
|
: !sql && !fd
|
||||||
|
? "new"
|
||||||
|
: fields.some((f) => f.changed)
|
||||||
|
? "changed"
|
||||||
|
: "present";
|
||||||
|
return { state, fields } as const;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user