Add Catalog Studio inspection and guided import review
CI / check (push) Successful in 1m39s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 13:10:43 +02:00
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);
});
});
+115
View File
@@ -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>
);
}
+134 -44
View File
@@ -67,6 +67,8 @@ import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot";
import { FurnitureInspector } from "./furniture-inspector";
import { ImportReview } from "./import-review";
import {
getFurniImageUrl,
handleImgError,
@@ -108,6 +110,12 @@ export function StudioClient({
initialTree: TreeNode[];
defaultTranslate: boolean;
}) {
const [review, setReview] = useState<{
items: FurniItem[];
single: boolean;
cloneAll?: boolean;
} | null>(null);
const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
const [selectedPage, setSelectedPage] = useState<number | null>(null);
@@ -389,44 +397,62 @@ export function StudioClient({
}
}, []);
async function cloneAllMissing() {
if (!activeSource) return;
// Fetch all clonable classnames
let classnames: string[] = [];
async function prepareCloneReview() {
if (!activeSource || preparingReview) return;
setPreparingReview(true);
try {
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
const response = await adminFetch(
"/api/admin/import/clone?source=" +
encodeURIComponent(activeSource) +
"&action=clonable",
);
const data = (await res.json()) as {
classnames?: string[];
error?: string;
};
if (!res.ok) {
toast.error(data.error || "Failed to fetch clonable items");
const result = await response.json();
if (!response.ok || !Array.isArray(result.classnames))
throw new Error(result.error || "Could not load missing furniture");
const missing = new Set<string>(result.classnames);
if (!missing.size) {
toast.info("All items from this source are already imported");
return;
}
classnames = data.classnames || [];
} catch {
toast.error("Network error fetching clonable items");
return;
const collected = new Map<string, FurniItem>();
let page = 1,
lastPage = 1;
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) {
toast.info("All items from this source are already imported");
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;
}
async function cloneAllMissing(classnames: string[]) {
if (!activeSource || !classnames.length) return;
// Initialize progress tracking as a Map for BatchProgress compatibility
const initial = new Map<string, BatchItemStatus>();
for (const cn of classnames) {
@@ -639,6 +665,7 @@ export function StudioClient({
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (review) return;
const tag = (e.target as HTMLElement)?.tagName;
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
@@ -656,7 +683,7 @@ export function StudioClient({
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [toggleAll]);
}, [toggleAll, review]);
async function importSingle(item: FurniItem) {
setImportingId(item.classname);
@@ -711,12 +738,18 @@ export function StudioClient({
}
}
async function importBatch(requested?: string[]) {
const toImport = (requested ? items : filteredItems).filter(
async function importBatch(
requested?: string[],
reviewedItems?: FurniItem[],
) {
const toImport = (
reviewedItems ?? (requested ? items : filteredItems)
).filter(
(i) =>
(requested
? requested.includes(i.classname)
: selected.has(i.classname)) && !i.alreadyImported,
: selected.has(i.classname)) &&
(!!reviewedItems || !i.alreadyImported),
);
if (toImport.length === 0) {
toast.info("Select furni that isn't imported yet");
@@ -1292,7 +1325,11 @@ export function StudioClient({
Catalog
</button>
<Select value={activeSource} onValueChange={changeSource}>
<Select
value={activeSource}
onValueChange={changeSource}
disabled={preparingReview}
>
<SelectTrigger
aria-label="Furniture source"
className="h-8 w-44 text-xs"
@@ -1638,9 +1675,16 @@ export function StudioClient({
<div className="flex-1" />
{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} />
Import all missing ({cloneStats.clonable})
{preparingReview
? "Preparing review…"
: `Import all missing (${cloneStats.clonable})`}
</Button>
)}
@@ -1649,7 +1693,14 @@ export function StudioClient({
size="sm"
className="gap-1.5"
disabled={!!batchProgress}
onClick={() => importBatch()}
onClick={() =>
setReview({
items: filteredItems.filter((item) =>
selected.has(item.classname),
),
single: false,
})
}
>
{batchProgress ? (
<Loader2 size={14} className="animate-spin" />
@@ -2035,7 +2086,12 @@ export function StudioClient({
{!item.alreadyImported && (
<button
type="button"
onClick={() => importSingle(item)}
onClick={() =>
setReview({
items: [item],
single: true,
})
}
disabled={importingId !== null}
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"
@@ -2149,13 +2205,14 @@ export function StudioClient({
{/* ── Detail drawer ─────────────────────────────── */}
{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">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
</span>
<button
type="button"
aria-label="Close furniture details"
onClick={() => setDetail(null)}
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>
<FurnitureInspector
key={`${detail.classname}-${detail.alreadyImported}`}
item={detail}
/>
{/* Auto-catalog preview */}
{detailPreview && (
<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
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={() => importSingle(detail)}
onClick={() => setReview({ items: [detail], single: true })}
>
{importingId === detail.classname ? (
<Loader2 size={14} className="animate-spin" />
@@ -2313,6 +2374,35 @@ export function StudioClient({
) : null}
</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
classname={editingClassname ?? ""}
name={detail?.name ?? ""}
+67
View File
@@ -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");
});
});
+70
View File
@@ -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;
}