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 { 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 ?? ""}
|
||||
|
||||
@@ -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