diff --git a/src/app/api/admin/studio/inspect/route.test.ts b/src/app/api/admin/studio/inspect/route.test.ts new file mode 100644 index 00000000..35600f8a --- /dev/null +++ b/src/app/api/admin/studio/inspect/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/admin/studio/inspect/route.ts b/src/app/api/admin/studio/inspect/route.ts new file mode 100644 index 00000000..37fdf5a3 --- /dev/null +++ b/src/app/api/admin/studio/inspect/route.ts @@ -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, + 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, + unknown, + ]; + let readable = true; + let data: Record = {}; + try { + data = await readFurniData(); + } catch { + readable = false; + } + const entries = new Map(); + for (const [section, type] of [ + ["roomitemtypes", "flooritem"], + ["wallitemtypes", "wallitem"], + ] as const) { + const list = ( + data[section] as + | { furnitype?: Array> } + | 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 }); + }, +); diff --git a/src/components/admin/studio/furniture-inspector.tsx b/src/components/admin/studio/furniture-inspector.tsx new file mode 100644 index 00000000..a187de92 --- /dev/null +++ b/src/components/admin/studio/furniture-inspector.tsx @@ -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 ( +
+
+ {( + [ + [".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]) => ( +
+
{label}
+
+ {status(present)} +
+
+ ))} +
+ {comparison.state === "conflict" && ( +

+ Multiple local records use this classname. Review them in Catalog + audit before importing. +

+ )} + {!local.furnidataReadable && ( +

+ Local furnidata could not be read. Its values are not verified. +

+ )} +
+ + + + + + + + + + {comparison.fields.map((field) => ( + + + + + + ))} + +
FieldLocal nowSource
+ {field.label} + {field.changed ? " · Changed" : ""} + + {field.before ?? "—"} + + {field.after ?? "Not supplied"} +
+
+

+ Source values are compared with local data. Import may remap IDs and + translate names; existing SQL items are skipped by this import flow. +

+
+ + SQL records and catalog offers + +
+ {local.sql.length ? ( + local.sql.map((row) => ( +

+ Item #{row.id} · Sprite #{row.spriteId} · {row.name} +

+ )) + ) : ( +

No local SQL item.

+ )} + {local.catalog.map((row) => ( +

+ Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "} + {row.points} points +

+ ))} +
+
+
+ ); +} +export function FurnitureInspector({ item }: { item: FurniItem }) { + const inspection = useFurnitureInspection([item.classname]); + if (inspection.loading) + return ( +

+ Checking local data and assets… +

+ ); + if (inspection.error) + return ( +
+

{inspection.error}

+ +
+ ); + const local = inspection.items.find( + (entry) => entry.classname === item.classname, + ); + return local ? ( + + ) : ( +

Local inspection unavailable.

+ ); +} diff --git a/src/components/admin/studio/import-review.tsx b/src/components/admin/studio/import-review.tsx new file mode 100644 index 00000000..8c09235c --- /dev/null +++ b/src/components/admin/studio/import-review.tsx @@ -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 ( + { + if (!open) onCancel(); + }} + > + + + + {step === "review" + ? "Review furniture import" + : "Confirm furniture import"} + + + 1. Select → 2. Review → 3. Import + + +
+
+

+ Source: {source} +

+

+ Destination: local catalog, furnidata, .nitro and + icons +

+

+ Translation: {translation} +

+

+ Selection: {items.length} furniture items +

+
+ {inspection.loading ? ( +

Checking local data and assets…

+ ) : inspection.error ? ( +
+

{inspection.error}

+ +
+ ) : !complete ? ( +

+ Inspection is incomplete. Close and retry before importing. +

+ ) : ( + <> +

+ {ready.length} ready to import · {existing} already in SQL + (skipped) · {blocked} conflicts (skipped) +

+ {inspection.items.some( + (item) => + !item.furnidataReadable || + item.icon.exists === null || + item.nitro.exists === null, + ) && ( +

+ Some local files could not be verified. Review the item + details before continuing. +

+ )} + {step === "review" ? ( +
+ {pageItems.map((item) => { + const local = localByName.get(item.classname); + if (!local) return null; + const comparison = compareFurniture(item, local); + return ( +
+ + {expanded === item.classname && ( +
+ +
+ )} +
+ ); + })} +
+ ) : ( + <> +

+ Only the {ready.length} eligible items below will be + submitted. Existing SQL items and conflicts are excluded. +

+

+ Automatic categories and prices below are estimates. Source + rules may override them; final results appear in the import + summary. +

+
    + {pageItems.map((item) => { + const placement = previewAutoCatalog( + item.classname, + item.type, + ); + return ( +
  • + {item.name} +

    + {placement.categoryLabel} · {placement.credits}{" "} + credits + {placement.points} points +

    +
  • + ); + })} +
+ + )} + + )} +
+ {!inspection.loading && !inspection.error && pageCount > 1 && ( +
+ + + Page {page + 1} / {pageCount} + + +
+ )} + + + + +
+
+ ); +} diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 1375a7d7..eebe8c62 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -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(initialTree); const [selectedPage, setSelectedPage] = useState(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(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(); + 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(); 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 - {cloneStats && cloneStats.clonable > 0 && !batchProgress && ( - )} @@ -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 ? ( @@ -2035,7 +2086,12 @@ export function StudioClient({ {!item.alreadyImported && (