diff --git a/src/app/api/admin/studio/import-jobs/route.ts b/src/app/api/admin/studio/import-jobs/route.ts index b91128b1..84504cc2 100644 --- a/src/app/api/admin/studio/import-jobs/route.ts +++ b/src/app/api/admin/studio/import-jobs/route.ts @@ -10,6 +10,7 @@ import { ImportJobStore } from "@/lib/services/furni-job-store"; import { drainFurnitureImports } from "@/lib/services/furni-job-worker"; const schema = z.object({ + mode: z.literal("repair").optional(), id: z.uuid().refine((value) => value[14] === "4"), sourceId: z.string().max(100).optional(), translate: z.boolean().default(false), diff --git a/src/app/api/admin/studio/inspect/route.ts b/src/app/api/admin/studio/inspect/route.ts index f22b3c1d..37792b9b 100644 --- a/src/app/api/admin/studio/inspect/route.ts +++ b/src/app/api/admin/studio/inspect/route.ts @@ -1,153 +1,15 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; -import { db } from "@/lib/db"; -import { normalizeClassname } from "@/lib/furni/classname"; -import { - type FurnitureInspection, - validateClassnames, -} from "@/lib/furni/studio-inspection"; +import { 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, -): Promise<{ exists: boolean | null; bytes: number }> { - try { - const stat = await fs.stat(path.join(directory, name)); - return { exists: stat.isFile(), bytes: stat.size }; - } catch (error) { - return { - exists: (error as NodeJS.ErrnoException).code === "ENOENT" ? false : null, - bytes: 0, - }; - } -} -async function assetCandidates(directory: string, names: string[]) { - const results = await Promise.all( - [...new Set(names)].map((name) => asset(directory, name)), - ); - return ( - results.find((result) => result.exists === true) ?? - results.find((result) => result.exists === null) ?? - results[0] - ); -} -// Read-only inspection lives outside import routes so it cannot enqueue a Git export. +import { inspectFurniture } from "@/lib/services/furniture-inspection"; export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { - const body = await request.json().catch(() => null); - const names = validateClassnames(body?.classnames); + const body = await request.json().catch(() => null), + names = validateClassnames(body?.classnames); if (!names) return apiError("Provide 1–500 valid furniture classnames", 400); - const namesSql = sql.join( - names.map((name) => sql`${normalizeClassname(name)}`), - sql`, `, - ); - const [rows] = (await db.execute( - sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE LOWER(TRIM(item_name)) IN (${namesSql})`, - )) as unknown as [ - Array, - unknown, - ]; - const [offers] = (await db.execute( - sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE LOWER(TRIM(ib.item_name)) IN (${namesSql})`, - )) as unknown as [ - Array, - 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 ?? ""); - const key = normalizeClassname(name); - if (!names.some((value) => normalizeClassname(value) === key)) continue; - const values = entries.get(key) ?? []; - values.push({ - id: Number(entry.id), - name: String(entry.name ?? ""), - description: String(entry.description ?? ""), - revision: Number(entry.revision ?? 0), - type, - }); - entries.set(key, values); - } - } - const dirs = await getFurniAssetDirs(); - const items: FurnitureInspection[] = []; - for (let offset = 0; offset < names.length; offset += 20) { - items.push( - ...(await Promise.all( - names.slice(offset, offset + 20).map(async (classname) => { - const variants = [ - classname, - ...rows - .filter( - (row) => - normalizeClassname(row.classname) === - normalizeClassname(classname), - ) - .map((row) => row.classname), - ]; - const [nitro, icon] = await Promise.all([ - assetCandidates( - dirs.nitroDir, - variants.map((name) => `${name.split("*")[0]}.nitro`), - ), - assetCandidates( - dirs.iconDir, - variants.map((name) => `${name.replaceAll("*", "_")}_icon.png`), - ), - ]); - return { - classname, - sql: rows.filter( - (row) => - normalizeClassname(row.classname) === - normalizeClassname(classname), - ), - catalog: offers.filter( - (row) => - normalizeClassname(row.classname) === - normalizeClassname(classname), - ), - furnidata: entries.get(normalizeClassname(classname)) ?? [], - furnidataReadable: readable, - nitro, - icon, - }; - }), - )), - ); - } - return apiOk({ items }); + return apiOk({ items: await inspectFurniture(names) }); }, ); diff --git a/src/components/admin/studio/furniture-inspector.tsx b/src/components/admin/studio/furniture-inspector.tsx index 5c45904a..5ff0703e 100644 --- a/src/components/admin/studio/furniture-inspector.tsx +++ b/src/components/admin/studio/furniture-inspector.tsx @@ -5,6 +5,7 @@ import { adminFetch } from "@/lib/admin-fetch"; import { compareFurniture, type FurnitureInspection, + furnitureHealth, } from "@/lib/furni/studio-inspection"; import type { FurniItem } from "./studio-types"; @@ -172,7 +173,15 @@ export function FurnitureComparison({ ); } -export function FurnitureInspector({ item }: { item: FurniItem }) { +export function FurnitureInspector({ + item, + onRepair, + busy = false, +}: { + item: FurniItem; + onRepair?: () => Promise; + busy?: boolean; +}) { const inspection = useFurnitureInspection([item.classname]); if (inspection.loading) return ( @@ -192,8 +201,54 @@ export function FurnitureInspector({ item }: { item: FurniItem }) { const local = inspection.items.find( (entry) => entry.classname === item.classname, ); - return local ? ( - + const health = local ? furnitureHealth(local) : null; + return local && health ? ( +
+
+ + { + { + complete: "Complete", + incomplete: "Needs repair", + new: "Not imported", + conflict: "Local conflict", + unknown: "Not verified", + }[health.state] + } + + {health.missing.length > 0 && ( +

Missing: {health.missing.join(", ")}

+ )} + {health.state === "conflict" && ( +

+ Duplicate records or mismatched IDs/types require review before + repair. +

+ )} + {health.state === "unknown" && ( +

+ Some local data could not be checked. Retry verification before + repair. +

+ )} + {health.state === "incomplete" && onRepair && ( + <> +

+ Repair adds missing parts and preserves existing IDs, settings, + furnidata and catalog offers. Missing offers use the automatic + category and price shown below. +

+ + + )} + +
+ +
) : (

Local inspection unavailable.

); diff --git a/src/components/admin/studio/furniture-jobs.tsx b/src/components/admin/studio/furniture-jobs.tsx index 7d3e19e9..b3fa53eb 100644 --- a/src/components/admin/studio/furniture-jobs.tsx +++ b/src/components/admin/studio/furniture-jobs.tsx @@ -46,7 +46,12 @@ export function useFurnitureJobs(onComplete: () => void) { }, [refresh]); const submit = async ( items: ImportJobItem[], - options: { sourceId?: string; translate: boolean; langs?: string[] }, + options: { + sourceId?: string; + translate: boolean; + langs?: string[]; + mode?: "repair"; + }, ) => { if (submitting.current) return false; submitting.current = true; @@ -144,6 +149,7 @@ export function FurnitureJobHistory({ onClick={() => void state.submit(failed, { sourceId: job.sourceId, + mode: job.mode, translate: job.translate, langs: job.langs, }) diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index b8569ee5..ed16c9f8 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -2082,8 +2082,26 @@ export function StudioClient({ j.state === "completed") + .map((j) => j.id) + .join(",")}`} item={detail} + busy={ + jobs.busy || + jobs.jobs.some( + (j) => + (j.state === "queued" || j.state === "running") && + j.items.some((i) => i.classname === detail.classname), + ) + } + onRepair={() => + jobs.submit([detail], { + sourceId: activeSource || undefined, + translate: false, + mode: "repair", + }) + } /> {/* Auto-catalog preview */} {detailPreview && ( @@ -2137,16 +2155,6 @@ export function StudioClient({ ) : (
- {!detail.nitroExists && ( <>

diff --git a/src/lib/furni/import-job.ts b/src/lib/furni/import-job.ts index 2f377518..c814e6b7 100644 --- a/src/lib/furni/import-job.ts +++ b/src/lib/furni/import-job.ts @@ -9,6 +9,7 @@ export interface ImportJobItem { attachmentId?: string; } export interface ImportJob { + mode?: "repair"; id: string; userId: number; createdAt: string; diff --git a/src/lib/furni/studio-inspection.test.ts b/src/lib/furni/studio-inspection.test.ts index 02226307..b46baeba 100644 --- a/src/lib/furni/studio-inspection.test.ts +++ b/src/lib/furni/studio-inspection.test.ts @@ -65,3 +65,39 @@ describe("studio inspection", () => { ).toBe("conflict"); }); }); + +import { furnitureHealth } from "./studio-inspection"; + +it("does not call SQL-only furniture complete", () => { + expect( + furnitureHealth({ + ...empty, + sql: [{ id: 1, spriteId: 2, name: "chair", type: "s" }], + }), + ).toMatchObject({ + state: "incomplete", + missing: ["Nitro", "Icon", "Furnidata", "Catalog offer"], + }); +}); +it("keeps inaccessible assets unverified", () => { + expect( + furnitureHealth({ ...empty, nitro: { exists: null, bytes: 0 } }).state, + ).toBe("unknown"); +}); +it("detects mismatched local sprite IDs", () => { + expect( + furnitureHealth({ + ...empty, + sql: [{ id: 1, spriteId: 2, name: "chair", type: "s" }], + furnidata: [ + { + id: 3, + name: "chair", + description: "", + revision: 1, + type: "flooritem", + }, + ], + }).state, + ).toBe("conflict"); +}); diff --git a/src/lib/furni/studio-inspection.ts b/src/lib/furni/studio-inspection.ts index 51bfe98f..a85fe6e5 100644 --- a/src/lib/furni/studio-inspection.ts +++ b/src/lib/furni/studio-inspection.ts @@ -68,3 +68,37 @@ export function compareFurniture( : "present"; return { state, fields } as const; } +export function furnitureHealth(local: FurnitureInspection) { + const missing: string[] = []; + if (local.nitro.exists === false) missing.push("Nitro"); + if (local.icon.exists === false) missing.push("Icon"); + if (local.furnidataReadable && !local.furnidata.length) + missing.push("Furnidata"); + if (!local.sql.length) missing.push("SQL item"); + if (!local.catalog.length) missing.push("Catalog offer"); + const sql = local.sql[0], + fd = local.furnidata[0]; + const conflict = + local.sql.length > 1 || + local.furnidata.length > 1 || + !!( + sql && + fd && + (sql.spriteId !== fd.id || + (sql.type === "i" ? "wallitem" : "flooritem") !== fd.type) + ); + const unknown = + !local.furnidataReadable || + local.nitro.exists === null || + local.icon.exists === null; + const state = conflict + ? "conflict" + : unknown + ? "unknown" + : !missing.length + ? "complete" + : missing.length === 5 + ? "new" + : "incomplete"; + return { state, missing } as const; +} diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 710dfd0f..e1dfcf41 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -513,6 +513,7 @@ export async function importSingleFurni(params: { skipFurniDataWrite?: boolean; updateExisting?: boolean; repairExisting?: boolean; + preserveAssets?: boolean; /** Validated matching bundle supplied by a queued import. */ providedNitro?: Buffer; onProgress?: (status: string) => void; @@ -702,14 +703,14 @@ export async function importSingleFurni(params: { let iconFailure = "", swfFailure = ""; const [iconOkResult, swfOk] = await Promise.all([ - preservingExisting && existsSync(iconPath) + (preservingExisting || params.preserveAssets) && existsSync(iconPath) ? Promise.resolve(true) : tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => { iconFailure = detail; }), - params.providedNitro + params.providedNitro || (params.preserveAssets && existsSync(nitroPath)) ? Promise.resolve(false) - : preservingExisting && existsSync(swfPath) + : (preservingExisting || params.preserveAssets) && existsSync(swfPath) ? Promise.resolve(true) : tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => { swfFailure = detail; @@ -753,7 +754,12 @@ export async function importSingleFurni(params: { ); } - if (!swfOk && !nitroDownloadOk && !params.providedNitro) + if ( + !swfOk && + !nitroDownloadOk && + !params.providedNitro && + !(params.preserveAssets && existsSync(nitroPath)) + ) warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`); // ── Convert SWF to Nitro ────────────────────────────────────────── diff --git a/src/lib/services/furni-job-worker.ts b/src/lib/services/furni-job-worker.ts index 205c109c..ccc483c9 100644 --- a/src/lib/services/furni-job-worker.ts +++ b/src/lib/services/furni-job-worker.ts @@ -9,6 +9,7 @@ import { readFurnitureAttachment } from "./furni-attachment"; import { patchLocalizedFurniDataEntries } from "./furni-data-i18n"; import { ensureDirectories, importSingleFurni } from "./furni-import"; import { ImportJobStore } from "./furni-job-store"; +import { repairFurniture } from "./furniture-repair"; import { rcon } from "./rcon"; const LOCK = "furniture-import-worker:v1"; @@ -77,7 +78,9 @@ async function drain() { job.userId, ) : undefined; - const result = await importSingleFurni({ + const result = await (job.mode === "repair" + ? repairFurniture + : importSingleFurni)({ ...item, repairExisting: true, providedNitro, @@ -100,7 +103,7 @@ async function drain() { repairExisting: true, }, }); - if (job.translate) + if (job.translate && job.mode !== "repair") try { await patchLocalizedFurniDataEntries([item], true, job.langs); } catch { diff --git a/src/lib/services/furniture-inspection.ts b/src/lib/services/furniture-inspection.ts new file mode 100644 index 00000000..07146846 --- /dev/null +++ b/src/lib/services/furniture-inspection.ts @@ -0,0 +1,145 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { normalizeClassname } from "@/lib/furni/classname"; +import type { FurnitureInspection } from "@/lib/furni/studio-inspection"; +import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; +import { readFurniData } from "@/lib/services/furni-data"; + +async function asset( + directory: string, + name: string, +): Promise<{ exists: boolean | null; bytes: number }> { + try { + const stat = await fs.stat(path.join(directory, name)); + return { + exists: stat.isFile() ? (stat.size > 0 ? true : null) : false, + bytes: stat.size, + }; + } catch (error) { + return { + exists: (error as NodeJS.ErrnoException).code === "ENOENT" ? false : null, + bytes: 0, + }; + } +} +async function assetCandidates(directory: string, names: string[]) { + const results = await Promise.all( + [...new Set(names)].map((name) => asset(directory, name)), + ); + return ( + results.find((result) => result.exists === true) ?? + results.find((result) => result.exists === null) ?? + results[0] + ); +} + +export async function inspectFurniture( + names: string[], +): Promise { + const namesSql = sql.join( + names.map((name) => sql`${normalizeClassname(name)}`), + sql`, `, + ); + const [rows] = (await db.execute( + sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE LOWER(TRIM(item_name)) IN (${namesSql})`, + )) as unknown as [ + Array, + unknown, + ]; + const [offers] = (await db.execute( + sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE LOWER(TRIM(ib.item_name)) IN (${namesSql})`, + )) as unknown as [ + Array, + 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 ?? ""); + const key = normalizeClassname(name); + if (!names.some((value) => normalizeClassname(value) === key)) continue; + const values = entries.get(key) ?? []; + values.push({ + id: Number(entry.id), + name: String(entry.name ?? ""), + description: String(entry.description ?? ""), + revision: Number(entry.revision ?? 0), + type, + }); + entries.set(key, values); + } + } + const dirs = await getFurniAssetDirs(); + const items: FurnitureInspection[] = []; + for (let offset = 0; offset < names.length; offset += 20) { + items.push( + ...(await Promise.all( + names.slice(offset, offset + 20).map(async (classname) => { + const variants = [ + classname, + ...rows + .filter( + (row) => + normalizeClassname(row.classname) === + normalizeClassname(classname), + ) + .map((row) => row.classname), + ]; + const [nitro, icon] = await Promise.all([ + assetCandidates( + dirs.nitroDir, + variants.map((name) => `${name.split("*")[0]}.nitro`), + ), + assetCandidates( + dirs.iconDir, + variants.map((name) => `${name.replaceAll("*", "_")}_icon.png`), + ), + ]); + return { + classname, + sql: rows.filter( + (row) => + normalizeClassname(row.classname) === + normalizeClassname(classname), + ), + catalog: offers.filter( + (row) => + normalizeClassname(row.classname) === + normalizeClassname(classname), + ), + furnidata: entries.get(normalizeClassname(classname)) ?? [], + furnidataReadable: readable, + nitro, + icon, + }; + }), + )), + ); + } + return items; +} diff --git a/src/lib/services/furniture-repair.test.ts b/src/lib/services/furniture-repair.test.ts new file mode 100644 index 00000000..1853b2ef --- /dev/null +++ b/src/lib/services/furniture-repair.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { FurnitureInspection } from "@/lib/furni/studio-inspection"; + +const mocks = vi.hoisted(() => ({ inspect: vi.fn(), import: vi.fn() })); +vi.mock("./furniture-inspection", () => ({ inspectFurniture: mocks.inspect })); +vi.mock("./furni-import", () => ({ importSingleFurni: mocks.import })); + +import { repairFurniture } from "./furniture-repair"; + +const complete: FurnitureInspection = { + classname: "chair", + sql: [{ id: 9, spriteId: 10, name: "Local name", type: "s" }], + furnidata: [ + { + id: 10, + name: "Local name", + description: "Keep", + revision: 1, + type: "flooritem", + }, + ], + furnidataReadable: true, + catalog: [{ id: 2, pageId: 3, credits: 5, points: 0 }], + nitro: { exists: true, bytes: 200 }, + icon: { exists: true, bytes: 100 }, +}; +const params = { + classname: "chair", + id: 50, + name: "Source name", + description: "Source", + type: "flooritem", + revision: 2, + category: "other", +}; +beforeEach(() => { + vi.resetAllMocks(); + mocks.import.mockResolvedValue({ ok: true, itemId: 9, warnings: [] }); +}); +it("does nothing for complete furniture", async () => { + mocks.inspect.mockResolvedValue([complete]); + expect((await repairFurniture(params)).ok).toBe(true); + expect(mocks.import).not.toHaveBeenCalled(); +}); +it("preserves local IDs, existing furnidata and assets and verifies afterward", async () => { + mocks.inspect + .mockResolvedValueOnce([{ ...complete, icon: { exists: false, bytes: 0 } }]) + .mockResolvedValueOnce([complete]); + expect((await repairFurniture(params)).ok).toBe(true); + expect(mocks.import).toHaveBeenCalledWith( + expect.objectContaining({ + id: 10, + repairExisting: true, + preserveAssets: true, + skipFurniDataWrite: true, + }), + ); + expect(mocks.inspect).toHaveBeenCalledTimes(2); +}); +it("does not report success when a component is still missing", async () => { + mocks.inspect.mockResolvedValue([ + { ...complete, icon: { exists: false, bytes: 0 } }, + ]); + expect(await repairFurniture(params)).toMatchObject({ + ok: false, + error: "Repair incomplete. Still missing: Icon", + }); +}); +it("blocks conflicting and unreadable local data before mutation", async () => { + mocks.inspect.mockResolvedValue([{ ...complete, furnidataReadable: false }]); + await expect(repairFurniture(params)).rejects.toThrow("blocked"); + expect(mocks.import).not.toHaveBeenCalled(); +}); diff --git a/src/lib/services/furniture-repair.ts b/src/lib/services/furniture-repair.ts new file mode 100644 index 00000000..e67e0642 --- /dev/null +++ b/src/lib/services/furniture-repair.ts @@ -0,0 +1,44 @@ +import { furnitureHealth } from "@/lib/furni/studio-inspection"; +import { importSingleFurni } from "./furni-import"; +import { inspectFurniture } from "./furniture-inspection"; +export async function repairFurniture( + params: Parameters[0], +): ReturnType { + const [before] = await inspectFurniture([params.classname]); + const health = furnitureHealth(before); + if (health.state === "conflict" || health.state === "unknown") + throw Error( + "Repair blocked: local data is conflicting or could not be verified", + ); + if (health.state === "complete") + return { + ok: true, + itemId: before.sql[0].id, + warnings: ["Already complete; no changes made"], + }; + const result = await importSingleFurni({ + ...params, + id: before.sql[0]?.spriteId ?? before.furnidata[0]?.id ?? params.id, + repairExisting: true, + preserveAssets: true, + skipFurniDataWrite: before.furnidata.length > 0, + }); + if (!result.ok) return result; + const [after] = await inspectFurniture([params.classname]); + const verified = furnitureHealth(after); + if (verified.state !== "complete") + return { + ...result, + ok: false, + error: verified.missing.length + ? `Repair incomplete. Still missing: ${verified.missing.join(", ")}` + : "Repair completed with unresolved local conflicts or unreadable data", + }; + return { + ...result, + warnings: [ + ...result.warnings, + "Verified: SQL, furnidata, Nitro, icon and catalog offer are present", + ], + }; +}