From 438277a17a85ebe9997ad0eac4678d92ab2b272d Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 15 Sep 2026 21:59:21 +0200 Subject: [PATCH] feat(studio): expand nitro cleanup with repair, auto-clean, and orphaned assets Scan distinguishes fake, broken, and orphaned SWF/icon assets with age metadata, deletes per asset kind, re-downloads broken nitro bundles from configured sources, auto-cleans old fake leftovers, and exports a JSON manifest. Adds rebuild and auto-clean API endpoints with audit coverage and a housekeeping preview route under the hotel domain. Verified: full vitest suite (2213 tests), typecheck, and biome all pass. --- .../admin-next/hotel/nitro-cleanup/page.tsx | 5 + .../studio/nitro-cleanup/auto-clean/route.ts | 54 +++ .../studio/nitro-cleanup/rebuild/route.ts | 51 ++ .../api/admin/studio/nitro-cleanup/route.ts | 33 +- .../admin/studio/nitro-cleanup-panel.tsx | 459 +++++++++++++++--- .../housekeeping/domains/hotel/manifest.ts | 9 +- .../foundation-source-contract.test.ts | 19 +- .../housekeeping/foundation/registry.test.ts | 15 +- src/features/housekeeping/migration/hotel.ts | 8 +- src/lib/services/nitro-cleanup.test.ts | 225 ++++++++- src/lib/services/nitro-cleanup.ts | 379 ++++++++++++--- src/messages/en.json | 8 +- src/messages/it.json | 8 +- 13 files changed, 1118 insertions(+), 155 deletions(-) create mode 100644 src/app/admin-next/hotel/nitro-cleanup/page.tsx create mode 100644 src/app/api/admin/studio/nitro-cleanup/auto-clean/route.ts create mode 100644 src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts diff --git a/src/app/admin-next/hotel/nitro-cleanup/page.tsx b/src/app/admin-next/hotel/nitro-cleanup/page.tsx new file mode 100644 index 00000000..676d1da4 --- /dev/null +++ b/src/app/admin-next/hotel/nitro-cleanup/page.tsx @@ -0,0 +1,5 @@ +import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel"; + +export default function HousekeepingHotelNitroCleanupPage() { + return ; +} diff --git a/src/app/api/admin/studio/nitro-cleanup/auto-clean/route.ts b/src/app/api/admin/studio/nitro-cleanup/auto-clean/route.ts new file mode 100644 index 00000000..ebcb4d6a --- /dev/null +++ b/src/app/api/admin/studio/nitro-cleanup/auto-clean/route.ts @@ -0,0 +1,54 @@ +import { apiError, apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; +import { logAudit } from "@/lib/services/audit"; +import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; +import { autoCleanFakeNitros } from "@/lib/services/nitro-cleanup"; + +/** Automatically remove fake .nitro leftovers older than `maxAgeDays`. */ +export const POST = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (request, ctx) => { + let body: { maxAgeDays?: unknown }; + try { + body = await request.json(); + } catch { + return apiError("Invalid JSON body", 400); + } + const maxAgeDays = + typeof body?.maxAgeDays === "number" && Number.isFinite(body.maxAgeDays) + ? Math.max(0, Math.floor(body.maxAgeDays)) + : 30; + try { + const result = await autoCleanFakeNitros(maxAgeDays); + clearFurniImportCache(); + if (result.deleted > 0) { + logAudit({ + userId: ctx.session.user.id, + action: "nitro_auto_cleanup", + target: "NitroFiles", + after: { + maxAgeDays, + deleted: result.deleted, + copiesRemoved: result.copiesRemoved, + skippedRecent: result.skippedRecent, + skippedBroken: result.skippedBroken, + errors: result.errors.length > 0 ? result.errors : undefined, + }, + }); + } + return apiOk({ + deleted: result.deleted, + copiesRemoved: result.copiesRemoved, + skippedRecent: result.skippedRecent, + skippedBroken: result.skippedBroken, + errors: result.errors.length > 0 ? result.errors : undefined, + }); + } catch (err) { + return apiError( + `Nitro auto-cleanup failed: ${(err as Error).message}`, + 500, + ); + } + }, +); diff --git a/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts b/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts new file mode 100644 index 00000000..e6e36fe0 --- /dev/null +++ b/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts @@ -0,0 +1,51 @@ +import { apiError, apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; +import { logAudit } from "@/lib/services/audit"; +import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; +import { repairBrokenNitros } from "@/lib/services/nitro-cleanup"; + +/** Re-download the given broken .nitro bundles from the configured sources. */ +export const POST = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (request, ctx) => { + let body: { fileNames?: unknown }; + try { + body = await request.json(); + } catch { + return apiError("Invalid JSON body", 400); + } + const fileNames = Array.isArray(body?.fileNames) + ? body.fileNames.filter( + (name): name is string => typeof name === "string", + ) + : []; + if (fileNames.length === 0) { + return apiError("fileNames[] is required", 400); + } + try { + const result = await repairBrokenNitros(fileNames); + clearFurniImportCache(); + if (result.repaired > 0) { + logAudit({ + userId: ctx.session.user.id, + action: "nitro_cleanup_repair", + target: "NitroFiles", + after: { + repaired: result.repaired, + failed: result.failed, + fileNames, + errors: result.errors.length > 0 ? result.errors : undefined, + }, + }); + } + return apiOk({ + repaired: result.repaired, + failed: result.failed, + errors: result.errors.length > 0 ? result.errors : undefined, + }); + } catch (err) { + return apiError(`Nitro repair failed: ${(err as Error).message}`, 500); + } + }, +); diff --git a/src/app/api/admin/studio/nitro-cleanup/route.ts b/src/app/api/admin/studio/nitro-cleanup/route.ts index 3875d573..4c69c0c5 100644 --- a/src/app/api/admin/studio/nitro-cleanup/route.ts +++ b/src/app/api/admin/studio/nitro-cleanup/route.ts @@ -4,29 +4,40 @@ import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; import { + type CleanupAssetKind, deleteNitroCleanupFiles, scanFakeBrokenNitros, } from "@/lib/services/nitro-cleanup"; -/** Scan all nitro directories for fake (orphaned) and broken (unparseable) .nitro files. */ +const VALID_KINDS: readonly CleanupAssetKind[] = ["nitro", "swf", "icon"]; + +/** + * Scan all asset directories for fake (orphaned) nitro / swf / icon files and + * broken (unparseable) .nitro bundles. + */ export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { try { const scan = await scanFakeBrokenNitros(); return apiOk({ fake: scan.fake, broken: scan.broken, + orphanedSwf: scan.orphanedSwf, + orphanedIcon: scan.orphanedIcon, total: scan.total, }); } catch (err) { - return apiError(`Nitro scan failed: ${(err as Error).message}`, 500); + return apiError(`Asset scan failed: ${(err as Error).message}`, 500); } }); -/** Delete the given .nitro files from every nitro directory (primary + mirrors). */ +/** + * Delete the given asset files (default .nitro) from every matching directory + * (public + mirrored gamedata). + */ export const DELETE = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { - let body: { fileNames?: unknown }; + let body: { fileNames?: unknown; kind?: unknown }; try { body = await request.json(); } catch { @@ -37,20 +48,26 @@ export const DELETE = withAdmin( (name): name is string => typeof name === "string", ) : []; + const kind: CleanupAssetKind = VALID_KINDS.includes( + body?.kind as CleanupAssetKind, + ) + ? (body.kind as CleanupAssetKind) + : "nitro"; if (fileNames.length === 0) { return apiError("fileNames[] is required", 400); } try { - const result = await deleteNitroCleanupFiles(fileNames); - // The deleted .nitro presence is cached by the Studio — drop it so + const result = await deleteNitroCleanupFiles(fileNames, kind); + // The deleted asset presence is cached by the Studio — drop it so // the furniture list reflects the removal immediately. clearFurniImportCache(); if (result.deleted > 0) { logAudit({ userId: ctx.session.user.id, action: "nitro_cleanup_delete", - target: "NitroFiles", + target: "AssetFiles", after: { + kind, deleted: result.deleted, copiesRemoved: result.copiesRemoved, fileNames, @@ -64,7 +81,7 @@ export const DELETE = withAdmin( errors: result.errors.length > 0 ? result.errors : undefined, }); } catch (err) { - return apiError(`Nitro cleanup failed: ${(err as Error).message}`, 500); + return apiError(`Asset cleanup failed: ${(err as Error).message}`, 500); } }, ); diff --git a/src/components/admin/studio/nitro-cleanup-panel.tsx b/src/components/admin/studio/nitro-cleanup-panel.tsx index b908e2cb..eefbfb40 100644 --- a/src/components/admin/studio/nitro-cleanup-panel.tsx +++ b/src/components/admin/studio/nitro-cleanup-panel.tsx @@ -3,30 +3,38 @@ import { AlertTriangle, CheckCircle2, + Download, FileX2, Loader2, ScanSearch, + ScrollText, + Sparkles, Trash2, + Wrench, } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; import { formatBytes } from "@/lib/services/disk-usage"; -interface NitroCleanupEntry { +interface AssetEntry { fileName: string; base: string; size: number; dirs: string[]; + lastModified: number; error?: string; } interface NitroCleanupScan { - fake: NitroCleanupEntry[]; - broken: NitroCleanupEntry[]; + fake: AssetEntry[]; + broken: AssetEntry[]; + orphanedSwf: AssetEntry[]; + orphanedIcon: AssetEntry[]; total: number; } @@ -36,45 +44,84 @@ interface DeleteResponse { errors?: string[]; } -type GroupKey = "fake" | "broken"; +interface RepairResponse { + repaired: number; + failed: number; + errors?: string[]; +} + +interface AutoCleanResponse { + deleted: number; + copiesRemoved: number; + skippedRecent: number; + skippedBroken: number; + errors?: string[]; +} + +type GroupTone = "error" | "warning" | "neutral"; +type AssetKind = "nitro" | "swf" | "icon"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function ageLabel(lastModified: number): string { + if (!lastModified) return "age unknown"; + const days = Math.floor((Date.now() - lastModified) / DAY_MS); + if (days < 1) return "< 1d"; + if (days < 30) return `${days}d`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}y`; +} + +function downloadJson(filename: string, data: unknown): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} function CleanupGroup({ - group, label, description, + tone, entries, selected, + disabled = false, onToggle, onToggleAll, }: { - group: GroupKey; label: string; description: string; - entries: NitroCleanupEntry[]; + tone: GroupTone; + entries: AssetEntry[]; selected: Set; + disabled?: boolean; onToggle: (fileName: string) => void; onToggleAll: (names: string[]) => void; }) { const checked = entries.filter((e) => selected.has(e.fileName)); const allChecked = entries.length > 0 && checked.length === entries.length; - const isError = group === "fake"; + const toneClass = + tone === "error" + ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" + : tone === "warning" + ? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" + : "border-[var(--admin-border)] bg-[var(--admin-surface)]"; return (
0 - ? isError - ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" - : "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" - : "border-[var(--admin-border)] bg-[var(--admin-surface)]" - }`} + className={`flex flex-wrap items-center gap-2 rounded-lg border p-3 ${entries.length > 0 ? toneClass : "border-[var(--admin-border)] bg-[var(--admin-surface)]"}`} > onToggleAll(entries.map((e) => e.fileName))} aria-label={`Select all ${label}`} /> @@ -111,6 +158,7 @@ function CleanupGroup({ style={{ display: "inline-flex", width: 16, height: 16 }} className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]" checked={selected.has(entry.fileName)} + disabled={disabled} onCheckedChange={() => onToggle(entry.fileName)} aria-label={`Select ${entry.fileName}`} /> @@ -120,6 +168,9 @@ function CleanupGroup({ {formatBytes(entry.size)} + + {ageLabel(entry.lastModified)} + {entry.error && ( (null); const [selected, setSelected] = useState>(new Set()); + const [selectedSwf, setSelectedSwf] = useState>(new Set()); + const [selectedIcon, setSelectedIcon] = useState>(new Set()); const { confirm, dialog } = useConfirmDialog(); const scanNow = useCallback(async () => { setScanning(true); setSelected(new Set()); + setSelectedSwf(new Set()); + setSelectedIcon(new Set()); try { const res = await adminFetch("/api/admin/studio/nitro-cleanup"); const data = (await res.json()) as NitroCleanupScan & { error?: string; }; if (!res.ok) throw new Error(data.error || "Scan failed"); - const fake = data.fake ?? []; - const broken = data.broken ?? []; setScan({ - fake, - broken, + fake: data.fake ?? [], + broken: data.broken ?? [], + orphanedSwf: data.orphanedSwf ?? [], + orphanedIcon: data.orphanedIcon ?? [], total: data.total ?? 0, }); - const found = fake.length + broken.length; + const found = + data.fake.length + + data.broken.length + + data.orphanedSwf.length + + data.orphanedIcon.length; if (found === 0) { - toast.success("No fake or broken .nitro files found"); + toast.success("No orphaned or broken furniture assets found"); } else { - toast.info(`Found ${found} removable .nitro file(s)`); + toast.info(`Found ${found} removable asset file(s)`); } } catch (err) { toast.error(err instanceof Error ? err.message : "Scan failed"); @@ -177,8 +241,14 @@ export function NitroCleanupPanel() { } }, []); - const toggle = (fileName: string) => { - setSelected((prev) => { + const toggle = (kind: AssetKind) => (fileName: string) => { + const setter = + kind === "swf" + ? setSelectedSwf + : kind === "icon" + ? setSelectedIcon + : setSelected; + setter((prev) => { const next = new Set(prev); if (next.has(fileName)) next.delete(fileName); else next.add(fileName); @@ -186,8 +256,14 @@ export function NitroCleanupPanel() { }); }; - const toggleAll = (names: string[]) => { - setSelected((prev) => { + const toggleAll = (kind: AssetKind) => (names: string[]) => { + const setter = + kind === "swf" + ? setSelectedSwf + : kind === "icon" + ? setSelectedIcon + : setSelected; + setter((prev) => { const next = new Set(prev); const allChecked = names.every((n) => next.has(n)); for (const name of names) { @@ -198,38 +274,61 @@ export function NitroCleanupPanel() { }); }; + const totalSelected = selected.size + selectedSwf.size + selectedIcon.size; + const removeSelected = async () => { - const fileNames = [...selected].sort(); - if (fileNames.length === 0) return; + const groups: Array<{ kind: AssetKind; names: string[] }> = []; + for (const [kind, set] of [ + ["nitro", selected], + ["swf", selectedSwf], + ["icon", selectedIcon], + ] as const) { + const names = [...set]; + if (names.length > 0) groups.push({ kind, names }); + } + if (groups.length === 0) return; const confirmed = await confirm({ - title: `Delete ${fileNames.length} .nitro file(s)?`, + title: `Delete ${totalSelected} asset file(s)?`, description: - "The .nitro bundles will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", + "The files will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", confirmLabel: "Delete", variant: "danger", }); if (!confirmed) return; setDeleting(true); try { - const res = await adminFetch("/api/admin/studio/nitro-cleanup", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileNames }), - }); - const data = (await res.json()) as DeleteResponse & { - error?: string; - }; - if (!res.ok) throw new Error(data.error || "Delete failed"); - if (data.errors?.length) { + let copiesRemoved = 0; + let deleted = 0; + let hadErrors = false; + for (const group of groups) { + const res = await adminFetch("/api/admin/studio/nitro-cleanup", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileNames: group.names.sort(), + kind: group.kind, + }), + }); + const data = (await res.json()) as DeleteResponse & { + error?: string; + }; + if (!res.ok) throw new Error(data.error || "Delete failed"); + deleted += data.deleted ?? 0; + copiesRemoved += data.copiesRemoved ?? 0; + if (data.errors?.length) hadErrors = true; + } + if (hadErrors) { toast.warning( - `Deleted ${data.copiesRemoved} copy/copies, but some files could not be removed`, + `Deleted ${copiesRemoved} copy/copies, but some files could not be removed`, ); } else { toast.success( - `Deleted ${data.copiesRemoved} .nitro file copy/copies (${data.deleted} file(s))`, + `Deleted ${copiesRemoved} file copy/copies (${deleted} file(s))`, ); } setSelected(new Set()); + setSelectedSwf(new Set()); + setSelectedIcon(new Set()); await scanNow(); } catch (err) { toast.error(err instanceof Error ? err.message : "Delete failed"); @@ -238,23 +337,148 @@ export function NitroCleanupPanel() { } }; + const repairSelected = async () => { + const fileNames = [...selected] + .filter((name) => scan?.broken.some((e) => e.fileName === name)) + .sort(); + if (fileNames.length === 0) return; + const confirmed = await confirm({ + title: `Re-download ${fileNames.length} broken .nitro bundle(s)?`, + description: + "The bundles will be fetched again from the configured nitro sources and written over every directory (public + mirrored gamedata).", + confirmLabel: "Re-download", + variant: "default", + }); + if (!confirmed) return; + setRepairing(true); + try { + const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileNames }), + }); + const data = (await res.json()) as RepairResponse & { + error?: string; + }; + if (!res.ok) throw new Error(data.error || "Repair failed"); + if (data.repaired > 0) { + toast.success(`Repaired ${data.repaired} .nitro bundle(s)`); + } else { + toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`); + } + setSelected(new Set()); + await scanNow(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Repair failed"); + } finally { + setRepairing(false); + } + }; + + const autoClean = async () => { + if (scan === null) return; + const confirmed = await confirm({ + title: `Auto-clean fake nitros older than ${autoAge} day(s)?`, + description: + "Only fake .nitro files (no matching item) older than the threshold are removed. Broken bundles and recent files are never touched.", + confirmLabel: "Auto-clean", + variant: "danger", + }); + if (!confirmed) return; + setAutoCleaning(true); + try { + const res = await adminFetch( + "/api/admin/studio/nitro-cleanup/auto-clean", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxAgeDays: autoAge }), + }, + ); + const data = (await res.json()) as AutoCleanResponse & { + error?: string; + }; + if (!res.ok) throw new Error(data.error || "Auto-clean failed"); + if (data.deleted > 0) { + toast.success( + `Auto-cleaned ${data.deleted} old fake .nitro file(s) (${data.copiesRemoved} copies). Skipped ${data.skippedRecent} recent + ${data.skippedBroken} broken.`, + ); + } else { + toast.info( + `Nothing old enough to remove (skipped ${data.skippedRecent} recent, ${data.skippedBroken} broken)`, + ); + } + await scanNow(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Auto-clean failed"); + } finally { + setAutoCleaning(false); + } + }; + + const exportManifest = () => { + if (scan === null) return; + const entries = { + scannedAt: new Date().toISOString(), + total: scan.total, + fake: scan.fake, + broken: scan.broken, + orphanedSwf: scan.orphanedSwf, + orphanedIcon: scan.orphanedIcon, + selected: { + nitro: [...selected].sort(), + swf: [...selectedSwf].sort(), + icon: [...selectedIcon].sort(), + }, + }; + downloadJson(`nitro-cleanup-scan-${Date.now()}.json`, entries); + toast.success("Scan manifest downloaded"); + }; + + const visibleFake = useMemo(() => { + if (scan === null) return []; + if (!onlyOld || minAgeDays <= 0) return scan.fake; + const threshold = minAgeDays * DAY_MS; + return scan.fake.filter((e) => e.lastModified <= Date.now() - threshold); + }, [scan, onlyOld, minAgeDays]); + + const visibleSwf = useMemo(() => { + if (scan === null) return []; + if (!onlyOld || minAgeDays <= 0) return scan.orphanedSwf; + const threshold = minAgeDays * DAY_MS; + return scan.orphanedSwf.filter( + (e) => e.lastModified <= Date.now() - threshold, + ); + }, [scan, onlyOld, minAgeDays]); + + const visibleIcon = useMemo(() => { + if (scan === null) return []; + if (!onlyOld || minAgeDays <= 0) return scan.orphanedIcon; + const threshold = minAgeDays * DAY_MS; + return scan.orphanedIcon.filter( + (e) => e.lastModified <= Date.now() - threshold, + ); + }, [scan, onlyOld, minAgeDays]); + + const busy = scanning || deleting || repairing || autoCleaning; + return (
{dialog}
- {scan && ( + )} + {scan && scan.broken.length > 0 && ( + + )} + {scan && scan.fake.length > 0 && ( + <> + +
+ setAutoAge(Number(e.target.value))} + aria-label="Minimum age in days for auto-clean" + /> + + days + +
+ + )} + {scan && ( + )}
{scan && (
+
+
+ + Only show files older than +
+ setMinAgeDays(Number(e.target.value))} + aria-label="Minimum age in days to display" + /> + days + +
+
{scan.total}
- .nitro files scanned + asset files scanned
@@ -281,7 +589,7 @@ export function NitroCleanupPanel() { {scan.fake.length}
- Fake (no matching item) + Fake nitros (no matching item)
@@ -289,28 +597,47 @@ export function NitroCleanupPanel() { {scan.broken.length}
- Broken (unparseable) + Broken nitros (unparseable)
+ + {(scan.fake.length > 0 || scan.broken.length > 0) && ( @@ -320,15 +647,15 @@ export function NitroCleanupPanel() { className="mt-0.5 shrink-0 text-[var(--admin-warning)]" /> Deleting a broken .nitro leaves its item in the database without a - bundle — the catalog audit can re-download it later. Database rows - are never removed by this tool. + bundle — prefer re-downloading it, or let the catalog audit + re-obtain it later. Database rows are never removed by this tool.

)}
- Only .nitro files are touched — icons, SWFs, FurnitureData.json and - database rows stay intact. + Only asset files are touched — FurnitureData.json and database rows + stay intact.
)} @@ -337,9 +664,9 @@ export function NitroCleanupPanel() {

- Scan every nitro directory to find fake bundles that belong to no - furniture and broken bundles that can no longer be parsed. Remove - them with one click. + Scan every furni asset directory to find fake and broken nitro + bundles plus orphaned SWFs and icons. Delete them, re-download the + broken ones, or auto-clean old leftovers.

)} diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts index 8c0ef95b..024e91cf 100644 --- a/src/features/housekeeping/domains/hotel/manifest.ts +++ b/src/features/housekeeping/domains/hotel/manifest.ts @@ -20,7 +20,14 @@ export const hotelManifest = { PERMS.PAGES_VIEW, PERMS.CATALOG_EDIT, ), - routes: [], + routes: [ + { + id: "nitro-cleanup", + labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup", + href: "/admin-next/hotel/nitro-cleanup", + capability: anyCapability(PERMS.ASSETS_IMPORT), + }, + ], searchProviders: [], inboxSources: [], widgets: [], diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 68291299..82597588 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -561,7 +561,7 @@ describe("housekeeping foundation completion contracts", () => { } }); - it("creates the real six-domain registry in locked order without workflows", () => { + it("creates the real six-domain registry in locked order without approved workflows", () => { const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); expect(registry.domains.map((domain) => domain.id)).toEqual([ @@ -572,9 +572,20 @@ describe("housekeeping foundation completion contracts", () => { "hotel", "system", ]); - expect(registry.domains.every((domain) => domain.routes.length === 0)).toBe( - true, - ); + expect( + registry.domains + .filter((domain) => domain.id !== "hotel") + .every((domain) => domain.routes.length === 0), + ).toBe(true); + expect( + registry.domains.find((domain) => domain.id === "hotel")?.routes, + ).toEqual([ + expect.objectContaining({ + id: "nitro-cleanup", + href: "/admin-next/hotel/nitro-cleanup", + labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup", + }), + ]); }); it("keeps production preview disabled even when the flag is true", () => { diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index d6d6ea81..7b4391f7 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -77,6 +77,7 @@ const expectedManifests = [ labelKey: "pages.housekeeping.domains.operations.title", descriptionKey: "pages.housekeeping.domains.operations.description", slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT], + routes: [], }, { id: "people", @@ -108,6 +109,7 @@ const expectedManifests = [ PERMS.MOD_CFH_EDIT, PERMS.MOD_TICKETS_EDIT, ], + routes: [], }, { id: "content", @@ -131,6 +133,7 @@ const expectedManifests = [ PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT, ], + routes: [], }, { id: "economy", @@ -144,6 +147,7 @@ const expectedManifests = [ PERMS.CATALOG_EDIT, PERMS.SHOP_EDIT, ], + routes: [], }, { id: "hotel", @@ -161,6 +165,14 @@ const expectedManifests = [ PERMS.PAGES_VIEW, PERMS.CATALOG_EDIT, ], + routes: [ + { + id: "nitro-cleanup", + labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup", + href: "/admin-next/hotel/nitro-cleanup", + capability: { mode: "any", slugs: [PERMS.ASSETS_IMPORT] }, + }, + ], }, { id: "system", @@ -180,6 +192,7 @@ const expectedManifests = [ PERMS.SETTINGS_EDIT, PERMS.NOTIFICATIONS_EDIT, ], + routes: [], }, ] as const; describe("housekeeping registry", () => { @@ -324,7 +337,7 @@ describe("housekeeping registry", () => { labelKey: expected.labelKey, descriptionKey: expected.descriptionKey, }); - expect(actual.routes).toEqual([]); + expect(actual.routes).toEqual(expected.routes); expect(actual.searchProviders).toEqual([]); expect(actual.inboxSources).toEqual([]); expect(actual.widgets).toEqual([]); diff --git a/src/features/housekeeping/migration/hotel.ts b/src/features/housekeeping/migration/hotel.ts index 520e9fbc..42ad0b91 100644 --- a/src/features/housekeeping/migration/hotel.ts +++ b/src/features/housekeeping/migration/hotel.ts @@ -586,15 +586,21 @@ export const hotelMigrationEntries: readonly MigrationEntry[] = [ dependencies: { queries: ["GET /api/admin/studio/nitro-cleanup", "scanFakeBrokenNitros"], mutations: [ + "autoCleanFakeNitros", "DELETE /api/admin/studio/nitro-cleanup", "clearFurniImportCache", "deleteNitroCleanupFiles", + "POST /api/admin/studio/nitro-cleanup/auto-clean", + "POST /api/admin/studio/nitro-cleanup/rebuild", + "repairBrokenNitros", ], }, auditRequirement: "PRIVILEGED_MUTATION", localization: "PARTIAL", accessibility: "PARTIAL", - notes: ["Deletes orphaned or unparseable nitro bundles from asset files"], + notes: [ + "Deletes orphaned or unparseable asset files and re-downloads broken nitro bundles from configured sources", + ], }), plannedHotelEntry({ surface: "admin", diff --git a/src/lib/services/nitro-cleanup.test.ts b/src/lib/services/nitro-cleanup.test.ts index fa64b354..aedfbdfe 100644 --- a/src/lib/services/nitro-cleanup.test.ts +++ b/src/lib/services/nitro-cleanup.test.ts @@ -9,9 +9,13 @@ const { readFileFn, statFn, unlinkFn, + writeFileFn, + mkdirFn, existsFn, parseNitroBundle, getTargetsFn, + listSourcesFn, + downloadFileFn, } = vi.hoisted(() => ({ executeFn: vi.fn(async () => [ [ @@ -25,6 +29,8 @@ const { readFileFn: vi.fn(), statFn: vi.fn(), unlinkFn: vi.fn(async () => {}), + writeFileFn: vi.fn(async () => {}), + mkdirFn: vi.fn(async () => {}), existsFn: vi.fn(() => false), parseNitroBundle: vi.fn(), getTargetsFn: vi.fn(async () => ({ @@ -33,6 +39,17 @@ const { iconDir: "/assets/icons", mirrorDirs: [], })), + listSourcesFn: vi.fn(async () => [ + { + id: "test", + name: "Test Source", + furnidataUrl: "https://src.test/gamedata/FurnitureData.json", + nitroBaseUrl: "https://src.test/nitro", + iconBaseUrl: "https://src.test/icons", + hotel: "nl", + }, + ]), + downloadFileFn: vi.fn(async () => ({ ok: true, size: 100 })), })); vi.mock("@/lib/db", () => ({ db: { execute: executeFn } })); @@ -40,6 +57,13 @@ vi.mock("@/lib/services/furni-asset-dirs", () => ({ getFurniAssetWriteTargets: getTargetsFn, })); vi.mock("@/lib/services/swf/nitro-builder", () => ({ parseNitroBundle })); +vi.mock("@/lib/services/clone-sources", () => ({ + listSources: listSourcesFn, + DEFAULT_NITRO_SOURCES: [], +})); +vi.mock("@/lib/services/import/core/download", () => ({ + downloadFile: downloadFileFn, +})); vi.mock("node:fs", async (orig) => { const real = (await orig()) as typeof import("node:fs"); return { @@ -50,13 +74,17 @@ vi.mock("node:fs", async (orig) => { readFile: readFileFn as AnyFn, stat: statFn as AnyFn, unlink: unlinkFn as AnyFn, + writeFile: writeFileFn as AnyFn, + mkdir: mkdirFn as AnyFn, }, existsSync: existsFn, }; }); import { + autoCleanFakeNitros, deleteNitroCleanupFiles, + repairBrokenNitros, scanFakeBrokenNitros, } from "@/lib/services/nitro-cleanup"; @@ -67,26 +95,51 @@ beforeEach(() => { parseNitroBundle.mockReset(); existsFn.mockReset(); getTargetsFn.mockReset(); + listSourcesFn.mockReset(); + downloadFileFn.mockReset(); + unlinkFn.mockReset(); + unlinkFn.mockImplementation(async () => {}); + writeFileFn.mockReset(); + writeFileFn.mockImplementation(async () => {}); + mkdirFn.mockReset(); + mkdirFn.mockImplementation(async () => {}); getTargetsFn.mockResolvedValue({ nitroDir: "/assets/nitro", swfDir: "/assets/swf", iconDir: "/assets/icons", mirrorDirs: [], }); + listSourcesFn.mockResolvedValue([ + { + id: "test", + name: "Test Source", + furnidataUrl: "https://src.test/gamedata/FurnitureData.json", + nitroBaseUrl: "https://src.test/nitro", + iconBaseUrl: "https://src.test/icons", + hotel: "nl", + }, + ]); + downloadFileFn.mockResolvedValue({ ok: true, size: 100 }); + statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 })); }); +function mockReaddir(byDir: Record): void { + readdirFn.mockImplementation(async (dir: string) => byDir[dir] ?? []); +} + describe("scanFakeBrokenNitros", () => { it("classifies fake (no matching item) and broken (unparseable) bundles", async () => { - readdirFn.mockResolvedValue([ - "chair.nitro", - "fake_stuff.nitro", - "broken_thing.nitro", - "xmas_.nitro", - ]); + mockReaddir({ + "/assets/nitro": [ + "chair.nitro", + "fake_stuff.nitro", + "broken_thing.nitro", + "xmas_.nitro", + ], + }); readFileFn.mockImplementation(async (filePath: string) => Buffer.from(filePath.includes("broken") ? "broken-data" : "nitro-data"), ); - statFn.mockImplementation(async () => ({ size: 512 })); parseNitroBundle.mockImplementation((buffer: Buffer) => { if (buffer.toString("utf-8").startsWith("broken")) { throw new Error("Not a valid .nitro bundle"); @@ -101,7 +154,6 @@ describe("scanFakeBrokenNitros", () => { const result = await scanFakeBrokenNitros(); - expect(result.total).toBe(4); expect(result.fake.map((e) => e.fileName)).toEqual(["fake_stuff.nitro"]); expect(result.broken.map((e) => e.fileName)).toEqual([ "broken_thing.nitro", @@ -110,9 +162,8 @@ describe("scanFakeBrokenNitros", () => { }); it("resolves star classnames to their base nitro stem", async () => { - readdirFn.mockResolvedValue(["xmas_.nitro"]); + mockReaddir({ "/assets/nitro": ["xmas_.nitro"] }); readFileFn.mockResolvedValue(Buffer.from("nitro-data")); - statFn.mockImplementation(async () => ({ size: 16 })); parseNitroBundle.mockReturnValue({ json: {}, jsonFileName: "visualization.json", @@ -128,14 +179,57 @@ describe("scanFakeBrokenNitros", () => { }); it("marks an unparseable bundle whose item is also missing as fake", async () => { - readdirFn.mockResolvedValue(["ghost.nitro"]); - statFn.mockImplementation(async () => ({ size: 8 })); + mockReaddir({ "/assets/nitro": ["ghost.nitro"] }); const result = await scanFakeBrokenNitros(); expect(result.fake[0].fileName).toBe("ghost.nitro"); expect(result.broken).toEqual([]); }); + + it("reports orphaned swf and icon files alongside nitro leftovers", async () => { + mockReaddir({ + "/assets/nitro": ["chair.nitro", "ghost.nitro"], + "/assets/swf": ["chair.swf", "phantom.swf"], + "/assets/icons": ["chair.gif", "phantom.gif", "phantom.png"], + }); + + const result = await scanFakeBrokenNitros(); + + expect(result.fake.map((e) => e.fileName)).toEqual(["ghost.nitro"]); + expect(result.orphanedSwf.map((e) => e.fileName)).toEqual(["phantom.swf"]); + expect(result.orphanedIcon.map((e) => e.fileName)).toEqual([ + "phantom.gif", + "phantom.png", + ]); + expect(result.total).toBe(7); + }); + + it("records the largest size and latest mtime across mirrored copies", async () => { + getTargetsFn.mockResolvedValue({ + nitroDir: "/a/nitro", + swfDir: "/a/swf", + iconDir: "/a/icons", + mirrorDirs: [ + { nitroDir: "/b/nitro", swfDir: "/b/swf", iconDir: "/b/icons" }, + ], + }); + mockReaddir({ "/a/nitro": ["ghost.nitro"], "/b/nitro": ["ghost.nitro"] }); + statFn.mockImplementation(async (filePath: string) => + filePath.startsWith("/a/") + ? { size: 100, mtimeMs: 1000 } + : { size: 900, mtimeMs: 2000 }, + ); + readFileFn.mockResolvedValue(Buffer.from("nitro-data")); + parseNitroBundle.mockReturnValue({}); + + const result = await scanFakeBrokenNitros(); + + expect(result.total).toBe(1); + expect(result.fake[0].dirs).toEqual(["/a/nitro", "/b/nitro"]); + expect(result.fake[0].size).toBe(900); + expect(result.fake[0].lastModified).toBe(2000); + }); }); describe("deleteNitroCleanupFiles", () => { @@ -192,4 +286,111 @@ describe("deleteNitroCleanupFiles", () => { expect(result.errors[0]).toContain("Invalid nitro file name"); expect(unlinkFn).not.toHaveBeenCalled(); }); + + it("deletes swf and icon files from their own directories", async () => { + existsFn.mockImplementation((filePath: string) => + filePath.includes("phantom"), + ); + + const swfResult = await deleteNitroCleanupFiles(["phantom.swf"], "swf"); + const iconResult = await deleteNitroCleanupFiles(["phantom.gif"], "icon"); + + expect(swfResult.copiesRemoved).toBe(2); + expect(unlinkFn).toHaveBeenCalledWith("/a/swf/phantom.swf"); + expect(unlinkFn).toHaveBeenCalledWith("/b/swf/phantom.swf"); + expect(iconResult.copiesRemoved).toBe(2); + expect(unlinkFn).toHaveBeenCalledWith("/a/icons/phantom.gif"); + expect(unlinkFn).toHaveBeenCalledWith("/b/icons/phantom.gif"); + }); + + it("rejects a swf name for the icon kind", async () => { + const result = await deleteNitroCleanupFiles(["phantom.gif"], "swf"); + expect(result.deleted).toBe(0); + expect(result.errors[0]).toContain("Invalid swf file name"); + expect(unlinkFn).not.toHaveBeenCalled(); + }); +}); + +describe("autoCleanFakeNitros", () => { + it("removes only fake leftovers older than the age threshold", async () => { + getTargetsFn.mockResolvedValue({ + nitroDir: "/a/nitro", + swfDir: "/a/swf", + iconDir: "/a/icons", + mirrorDirs: [], + }); + mockReaddir({ + "/a/nitro": [ + "old_fake.nitro", + "chair.nitro", + "recent_fake.nitro", + "broken_thing.nitro", + ], + }); + const now = Date.now(); + statFn.mockImplementation(async (filePath: string) => ({ + size: 10, + mtimeMs: filePath.includes("old_fake") + ? now - 60 * 24 * 60 * 60 * 1000 + : now, + })); + existsFn.mockImplementation(() => true); + readFileFn.mockResolvedValue(Buffer.from("broken-data")); + parseNitroBundle.mockImplementation(() => { + throw new Error("Not a valid .nitro bundle"); + }); + + const result = await autoCleanFakeNitros(30); + + expect(result.deleted).toBe(1); + expect(result.copiesRemoved).toBe(1); + expect(unlinkFn).toHaveBeenCalledWith("/a/nitro/old_fake.nitro"); + expect(result.skippedRecent).toBe(1); + expect(result.skippedBroken).toBe(2); + }); +}); + +describe("repairBrokenNitros", () => { + it("re-downloads valid bundles and writes them to every nitro dir", async () => { + getTargetsFn.mockResolvedValue({ + nitroDir: "/a/nitro", + swfDir: "/a/swf", + iconDir: "/a/icons", + mirrorDirs: [], + }); + readFileFn.mockResolvedValue(Buffer.from("nitro-data")); + parseNitroBundle.mockReturnValue({}); + + const result = await repairBrokenNitros(["broken_thing.nitro"]); + + expect(result.repaired).toBe(1); + expect(result.failed).toBe(0); + expect(downloadFileFn).toHaveBeenCalledWith( + "https://src.test/nitro/broken_thing.nitro", + expect.any(String), + expect.objectContaining({ validate: "nitro" }), + ); + expect(writeFileFn).toHaveBeenCalledWith( + expect.stringContaining("/a/nitro/broken_thing.nitro"), + expect.any(Buffer), + ); + }); + + it("rejects unsafe file names without downloading", async () => { + const result = await repairBrokenNitros(["../evil.nitro"]); + + expect(result.repaired).toBe(0); + expect(result.failed).toBe(1); + expect(downloadFileFn).not.toHaveBeenCalled(); + }); + + it("fails when no source can provide the bundle", async () => { + downloadFileFn.mockResolvedValue({ ok: false, size: 0, error: "HTTP 404" }); + + const result = await repairBrokenNitros(["broken_thing.nitro"]); + + expect(result.repaired).toBe(0); + expect(result.failed).toBe(1); + expect(result.errors[0]).toMatch(/could not re-obtain/); + }); }); diff --git a/src/lib/services/nitro-cleanup.ts b/src/lib/services/nitro-cleanup.ts index 33807c59..9c7d245c 100644 --- a/src/lib/services/nitro-cleanup.ts +++ b/src/lib/services/nitro-cleanup.ts @@ -1,50 +1,91 @@ import { existsSync, promises as fs } from "node:fs"; +import os from "node:os"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { getBaseClassname, getSafeClassnameStem } from "@/lib/furni/classname"; +import { + DEFAULT_NITRO_SOURCES, + listSources, +} from "@/lib/services/clone-sources"; import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; +import { downloadFile } from "@/lib/services/import/core/download"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { getRuntimePath } from "@/lib/utils/runtime-path"; /** * Catalog Studio nitro cleanup. * - * Scans every .nitro directory (public + mirrored gamedata) and splits the - * found bundles into two removable groups: + * Scans every asset directory (public + mirrored gamedata) for furniture + * leftovers and splits the found bundles into removable groups: * * - "fake" .nitro files whose stem does not match any item in `items_base` * (leftover files that belong to no furniture — including items that were * deleted or imported under a different name); * - "broken" .nitro files that do belong to an item but cannot be parsed as a - * valid Nitro bundle (truncated, corrupted or renamed files). + * valid Nitro bundle (truncated, corrupted or renamed files); + * - orphaned `.swf` / icon files whose stem matches no item in `items_base`. * - * Deletion only touches the .nitro files on disk — never database rows. + * Deletion only touches the asset files on disk — never database rows. Broken + * bundles can be re-downloaded from the configured nitro sources instead of + * being deleted, and fake bundles older than a configurable age can be removed + * automatically for self-cleaning nitro directories. */ -export interface NitroCleanupEntry { +export type CleanupAssetKind = "nitro" | "swf" | "icon"; + +export interface AssetCleanupEntry { fileName: string; base: string; size: number; dirs: string[]; + lastModified: number; +} + +export interface NitroCleanupEntry extends AssetCleanupEntry { error?: string; } export interface NitroCleanupScan { fake: NitroCleanupEntry[]; broken: NitroCleanupEntry[]; + orphanedSwf: AssetCleanupEntry[]; + orphanedIcon: AssetCleanupEntry[]; total: number; } export interface NitroCleanupDeleteResult { - /** Files that were fully removed from every nitro directory. */ + /** Files that were fully removed from every matching directory. */ deleted: number; - /** Individual .nitro files removed across all directories (mirrors). */ + /** Individual files removed across all directories (mirrors). */ copiesRemoved: number; errors: string[]; files: Array<{ fileName: string; deleted: boolean; remaining: string[] }>; } +export interface NitroRepairResult { + repaired: number; + failed: number; + errors: string[]; + files: Array<{ fileName: string; ok: boolean; error?: string }>; +} + +export interface NitroAutoCleanResult extends NitroCleanupDeleteResult { + /** Fake bundles skipped because they are younger than the age threshold. */ + skippedRecent: number; + /** Broken bundles skipped — only fake leftovers are auto-removed. */ + skippedBroken: number; +} + const NITRO_FILE_RE = /^[a-z0-9_*\-.]+\.nitro$/i; +const SWF_FILE_RE = /^[a-z0-9_*\-.]+\.swf$/i; +const ICON_FILE_RE = /^[a-z0-9_*\-.]+\.(?:gif|png)$/i; +const KIND_FILE_RE: Record = { + nitro: NITRO_FILE_RE, + swf: SWF_FILE_RE, + icon: ICON_FILE_RE, +}; + +const DAY_MS = 24 * 60 * 60 * 1000; function uniqueDirs(dirs: string[]): string[] { const seen = new Set(); @@ -90,34 +131,67 @@ function addStems(set: Set, classname: string): void { } } -export async function scanFakeBrokenNitros(): Promise { - const targets = await getFurniAssetWriteTargets(); - const nitroDirs = uniqueDirs([ - targets.nitroDir, - ...targets.mirrorDirs.map((d) => d.nitroDir), - ]); +interface DirectoryRead { + name: string; + base: string; + size: number; + lastModified: number; +} - // name → dirs + largest size seen anywhere. - const byName = new Map(); - for (const dir of nitroDirs) { - const names = await fs.readdir(dir).catch(() => [] as string[]); - for (const name of names) { - if (!NITRO_FILE_RE.test(name)) continue; - const info = byName.get(name) ?? { dirs: [], size: 0 }; - info.dirs.push(dir); - try { - const stat = await fs.stat(getRuntimePath(dir, name)); - info.size = Math.max(info.size, stat.size); - } catch { - /* ignore unreadable entry */ - } - byName.set(name, info); +async function readAssetDir( + dir: string, + regex: RegExp, +): Promise { + const names = await fs.readdir(dir).catch(() => [] as string[]); + const entries: DirectoryRead[] = []; + for (const name of names) { + if (!regex.test(name)) continue; + let size = 0; + let mtimeMs = 0; + try { + const stat = await fs.stat(getRuntimePath(dir, name)); + size = stat.size; + mtimeMs = stat.mtimeMs; + } catch { + /* ignore unreadable entry */ + } + entries.push({ + name, + base: name.replace(/\.[a-z0-9]+$/i, ""), + size, + lastModified: mtimeMs, + }); + } + return entries; +} + +async function collectAssets( + dirs: string[], + regex: RegExp, +): Promise> { + const byName = new Map(); + for (const dir of dirs) { + for (const read of await readAssetDir(dir, regex)) { + const entry = byName.get(read.name) ?? { + fileName: read.name, + base: read.base, + size: 0, + dirs: [], + lastModified: 0, + }; + if (!entry.dirs.includes(dir)) entry.dirs.push(dir); + entry.size = Math.max(entry.size, read.size); + entry.lastModified = Math.max(entry.lastModified, read.lastModified); + byName.set(read.name, entry); } } + return byName; +} +async function loadValidStems(): Promise> { // Authoritative stem set from items_base (item_name + public_name // fallback), mirroring repair-nitros.ts classname resolution. - let rows: Array<{ item_name: string; public_name: string }> = []; + const validStems = new Set(); try { const [result] = (await db.execute(sql` SELECT item_name, public_name FROM items_base @@ -125,30 +199,46 @@ export async function scanFakeBrokenNitros(): Promise { Array<{ item_name: string; public_name: string }>, unknown, ]; - rows = result; - } catch { - /* empty set → every .nitro is treated as fake */ - } - const validStems = new Set(); - for (const row of rows) { - addStems(validStems, row.item_name); - if ( - row.public_name && - row.public_name !== row.item_name && - !row.public_name.includes(" ") && - row.public_name !== "0" - ) { - addStems(validStems, row.public_name); + for (const row of result) { + addStems(validStems, row.item_name); + if ( + row.public_name && + row.public_name !== row.item_name && + !row.public_name.includes(" ") && + row.public_name !== "0" + ) { + addStems(validStems, row.public_name); + } } + } catch { + /* empty set → every asset is treated as orphaned */ } + return validStems; +} - const entries: NitroCleanupEntry[] = [...byName].map(([fileName, info]) => ({ - fileName, - base: fileName.slice(0, -".nitro".length), - size: info.size, - dirs: info.dirs, - })); +export async function scanFakeBrokenNitros(): Promise { + const targets = await getFurniAssetWriteTargets(); + const nitroDirs = uniqueDirs([ + targets.nitroDir, + ...targets.mirrorDirs.map((d) => d.nitroDir), + ]); + const swfDirs = uniqueDirs([ + targets.swfDir, + ...targets.mirrorDirs.map((d) => d.swfDir), + ]); + const iconDirs = uniqueDirs([ + targets.iconDir, + ...targets.mirrorDirs.map((d) => d.iconDir), + ]); + const [byNitro, bySwf, byIcon, validStems] = await Promise.all([ + collectAssets(nitroDirs, NITRO_FILE_RE), + collectAssets(swfDirs, SWF_FILE_RE), + collectAssets(iconDirs, ICON_FILE_RE), + loadValidStems(), + ]); + + const entries: NitroCleanupEntry[] = [...byNitro.values()]; const fake: NitroCleanupEntry[] = []; const broken: NitroCleanupEntry[] = []; @@ -177,21 +267,46 @@ export async function scanFakeBrokenNitros(): Promise { } }); - fake.sort((a, b) => a.fileName.localeCompare(b.fileName)); - broken.sort((a, b) => a.fileName.localeCompare(b.fileName)); + const orphanedSwf = [...bySwf.values()].filter( + (entry) => !validStems.has(entry.base.toLowerCase()), + ); + const orphanedIcon = [...byIcon.values()].filter( + (entry) => !validStems.has(entry.base.toLowerCase()), + ); - return { fake, broken, total: byName.size }; + const byName = (entries: T[]): T[] => + entries.sort((a, b) => a.fileName.localeCompare(b.fileName)); + + return { + fake: byName(fake), + broken: byName(broken), + orphanedSwf: byName(orphanedSwf), + orphanedIcon: byName(orphanedIcon), + total: byNitro.size + bySwf.size + byIcon.size, + }; } -/** Delete .nitro files from every nitro directory (primary + mirrors). */ +function dirsForKind( + targets: Awaited>, + kind: CleanupAssetKind, +): string[] { + const primary = { + nitro: targets.nitroDir, + swf: targets.swfDir, + icon: targets.iconDir, + }[kind]; + const mirrors = targets.mirrorDirs.map((d) => d[`${kind}Dir` as const]); + return uniqueDirs([primary, ...mirrors]); +} + +/** Delete asset files from every matching directory (primary + mirrors). */ export async function deleteNitroCleanupFiles( fileNames: string[], + kind: CleanupAssetKind = "nitro", ): Promise { const targets = await getFurniAssetWriteTargets(); - const nitroDirs = uniqueDirs([ - targets.nitroDir, - ...targets.mirrorDirs.map((d) => d.nitroDir), - ]); + const dirs = dirsForKind(targets, kind); + const fileRegex = KIND_FILE_RE[kind]; let deleted = 0; let copiesRemoved = 0; @@ -200,14 +315,14 @@ export async function deleteNitroCleanupFiles( for (const rawName of [...new Set(fileNames)]) { const fileName = String(rawName ?? ""); - if (!NITRO_FILE_RE.test(fileName)) { - errors.push(`Invalid nitro file name: ${fileName}`); + if (!fileRegex.test(fileName)) { + errors.push(`Invalid ${kind} file name: ${fileName}`); files.push({ fileName, deleted: false, remaining: [] }); continue; } const remaining: string[] = []; let existed = false; - for (const dir of nitroDirs) { + for (const dir of dirs) { const filePath = getRuntimePath(dir, fileName); if (!existsSync(filePath)) continue; existed = true; @@ -226,3 +341,147 @@ export async function deleteNitroCleanupFiles( return { deleted, copiesRemoved, errors, files }; } + +function ageDays(lastModified: number): number { + if (!lastModified) return 0; + return Math.max(0, Math.floor((Date.now() - lastModified) / DAY_MS)); +} + +/** + * Automatically remove fake .nitro leftovers that are older than + * `maxAgeDays`. Broken bundles and young fake bundles are never touched — + * only former leftovers whose items are long gone from the database. + */ +export async function autoCleanFakeNitros( + maxAgeDays = 30, +): Promise { + const scan = await scanFakeBrokenNitros(); + const threshold = Math.max(0, Math.floor(maxAgeDays)); + const removable = scan.fake.filter( + (entry) => ageDays(entry.lastModified) >= threshold, + ); + const result = await deleteNitroCleanupFiles( + removable.map((entry) => entry.fileName), + "nitro", + ); + return { + ...result, + skippedRecent: scan.fake.length - removable.length, + skippedBroken: scan.broken.length, + }; +} + +async function copyFileToDirs( + buffer: Buffer, + fileName: string, + dirs: string[], +): Promise { + const errors: string[] = []; + for (const dir of dirs) { + try { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(getRuntimePath(dir, fileName), buffer); + } catch (err) { + errors.push(`write .nitro to ${dir} failed: ${(err as Error).message}`); + } + } + return errors; +} + +/** + * Re-download broken .nitro bundles from the configured nitro sources. The + * file stem matched an item in `items_base`, so the same classname candidates + * used by repair-nitros.ts are tried; every downloaded bundle is validated + * before it replaces the broken copies across all nitro directories. + */ +export async function repairBrokenNitros( + fileNames: string[], +): Promise { + const targets = await getFurniAssetWriteTargets(); + const nitroDirs = dirsForKind(targets, "nitro"); + let sources = (await listSources().catch(() => [] as never)).filter( + (source: { nitroBaseUrl?: string }) => source.nitroBaseUrl, + ); + if (sources.length === 0) sources = DEFAULT_NITRO_SOURCES as never; + + let repaired = 0; + let failed = 0; + const errors: string[] = []; + const files: NitroRepairResult["files"] = []; + + await runPool([...new Set(fileNames)], 6, async (rawName) => { + const fileName = String(rawName ?? ""); + if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) { + failed++; + const message = `Invalid nitro file name: ${fileName}`; + errors.push(message); + files.push({ fileName, ok: false, error: message }); + return; + } + const name = fileName.slice(0, -".nitro".length); + const candidates = [ + name, + ...(name.includes("*") + ? [name.split("*")[0], name.replace(/\*/g, "_")] + : []), + ...(name.includes(".") ? [name.replace(/\./g, "_")] : []), + ]; + for (const source of sources as { + name: string; + nitroBaseUrl?: string; + }[]) { + for (const candidate of candidates) { + const base = source.nitroBaseUrl; + if (!base) continue; + const tmpPath = getRuntimePath( + os.tmpdir(), + `.repair_nitro_${name}_${Math.random().toString(36).slice(2, 8)}.nitro`, + ); + try { + const dl = await downloadFile( + `${base.replace(/\/+$/, "")}/${encodeURIComponent(candidate)}.nitro`, + tmpPath, + { maxRetries: 1, validate: "nitro" }, + ); + if (dl.ok) { + const buffer = await fs.readFile(tmpPath); + parseNitroBundle(buffer); + const writeErrors = await copyFileToDirs( + buffer, + fileName, + nitroDirs, + ); + if (writeErrors.length === 0) { + repaired++; + files.push({ fileName, ok: true }); + } else { + failed++; + errors.push(...writeErrors); + files.push({ + fileName, + ok: false, + error: writeErrors[0], + }); + } + return; + } + } catch (err) { + errors.push( + `repair ${fileName} from ${source.name} failed: ${(err as Error).message}`, + ); + } finally { + await fs.unlink(tmpPath).catch(() => {}); + } + } + } + failed++; + errors.push(`could not re-obtain ${fileName} from any nitro source`); + files.push({ + fileName, + ok: false, + error: "could not re-obtain bundle from any nitro source", + }); + }); + + return { repaired, failed, errors, files }; +} diff --git a/src/messages/en.json b/src/messages/en.json index 89f38d83..407f394c 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -4771,7 +4771,13 @@ }, "hotel": { "title": "Hotel", - "description": "Rooms, radio, and asset tools" + "description": "Rooms, radio, and asset tools", + "routes": { + "nitroCleanup": { + "title": "Nitro cleanup", + "description": "Scan and remove fake, broken, and orphaned furni asset files" + } + } }, "system": { "title": "System", diff --git a/src/messages/it.json b/src/messages/it.json index ba008a0b..6e7fe1c9 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -4770,7 +4770,13 @@ }, "hotel": { "title": "Hotel", - "description": "Stanze, radio e strumenti per gli asset" + "description": "Stanze, radio e strumenti per gli asset", + "routes": { + "nitroCleanup": { + "title": "Pulizia nitro", + "description": "Scansiona e rimuovi i file asset furni falsi, danneggiati o orfani" + } + } }, "system": { "title": "Sistema",