From 6c53f4680c2f21d80779c0516e00a6c7bbfaef27 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 19 Sep 2026 13:41:14 +0200 Subject: [PATCH] feat(studio): run nitro scans in the background with cancel-re-attach and nightly auto-clean --- scripts/jobs-worker.ts | 31 + .../studio/nitro-cleanup/auto-clean/route.ts | 10 +- .../studio/nitro-cleanup/cancel/route.ts | 13 + .../studio/nitro-cleanup/history/route.ts | 10 + .../studio/nitro-cleanup/rebuild/route.ts | 9 + .../api/admin/studio/nitro-cleanup/route.ts | 201 +++-- .../admin/studio/nitro-cleanup/run/route.ts | 49 ++ .../studio/nitro-cleanup/session/route.ts | 14 + .../admin/studio/nitro-cleanup-panel.tsx | 755 +++++++++++++----- src/lib/services/nitro-cleanup.test.ts | 213 +++++ src/lib/services/nitro-cleanup.ts | 176 +++- src/lib/services/nitro-scan-session.ts | 225 ++++++ src/messages/en.json | 72 ++ src/messages/nl.json | 72 ++ 14 files changed, 1547 insertions(+), 303 deletions(-) create mode 100644 src/app/api/admin/studio/nitro-cleanup/cancel/route.ts create mode 100644 src/app/api/admin/studio/nitro-cleanup/history/route.ts create mode 100644 src/app/api/admin/studio/nitro-cleanup/run/route.ts create mode 100644 src/app/api/admin/studio/nitro-cleanup/session/route.ts create mode 100644 src/lib/services/nitro-scan-session.ts diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index b7e8d5fd..9bf4f092 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -19,6 +19,7 @@ import { parseDfOutput, } from "../src/lib/services/disk-usage"; import { publishDueArticles } from "../src/lib/services/news-scheduler"; +import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup"; import { rcon } from "../src/lib/services/rcon"; function captureWorkerError(err: unknown, context: string): void { @@ -353,6 +354,27 @@ async function publishScheduledArticles(): Promise { } } +/** Nightly self-cleaning of old fake .nitro leftovers (skips when a scan is + * running in the studio). Requires the `items_base` truth list, so a DB outage + * shows up here as a captured error instead of deleting anything. */ +async function autoCleanNitroFakes(): Promise { + try { + const result = await scheduledAutoCleanFakeNitros(30); + if (result.skipped) { + logger.info("Skipped nightly nitro auto-clean: scan in progress", { + module: "jobs", + }); + } else { + logger.info("Nightly nitro auto-clean finished", { + module: "jobs", + deleted: result.deleted, + }); + } + } catch (err) { + captureWorkerError(err, "Nitro auto-clean failed"); + } +} + async function reportWorkerHeartbeat(): Promise { if (!redis) return; await redis @@ -433,6 +455,15 @@ async function main() { void reportWorkerHeartbeat(); }); await reportWorkerHeartbeat(); + + new Cron("0 2 * * *", () => { + autoCleanNitroFakes().catch((e) => + captureWorkerError(e, "Nitro auto-clean error"), + ); + }); + logger.info("Scheduled: nitro auto-clean (daily 02:00)", { + module: "jobs", + }); await Promise.all([ backupEmulatorJar(), cleanupOldLogs(), 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 index ebcb4d6a..5939e66e 100644 --- a/src/app/api/admin/studio/nitro-cleanup/auto-clean/route.ts +++ b/src/app/api/admin/studio/nitro-cleanup/auto-clean/route.ts @@ -3,7 +3,10 @@ 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"; +import { + autoCleanFakeNitros, + finishAutoClean, +} from "@/lib/services/nitro-cleanup"; /** Automatically remove fake .nitro leftovers older than `maxAgeDays`. */ export const POST = withAdmin( @@ -21,6 +24,11 @@ export const POST = withAdmin( : 30; try { const result = await autoCleanFakeNitros(maxAgeDays); + await finishAutoClean({ + result, + maxAgeDays, + triggeredBy: "manual", + }); clearFurniImportCache(); if (result.deleted > 0) { logAudit({ diff --git a/src/app/api/admin/studio/nitro-cleanup/cancel/route.ts b/src/app/api/admin/studio/nitro-cleanup/cancel/route.ts new file mode 100644 index 00000000..e848d8f7 --- /dev/null +++ b/src/app/api/admin/studio/nitro-cleanup/cancel/route.ts @@ -0,0 +1,13 @@ +import { apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; +import { cancelScanSession } from "@/lib/services/nitro-scan-session"; + +/** + * Abort the running background scan (in-process, or by marking the session + * cancelled when the process that owns it is not the one serving the request). + */ +export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { + const session = await cancelScanSession(); + return apiOk({ session }); +}); diff --git a/src/app/api/admin/studio/nitro-cleanup/history/route.ts b/src/app/api/admin/studio/nitro-cleanup/history/route.ts new file mode 100644 index 00000000..7af2e427 --- /dev/null +++ b/src/app/api/admin/studio/nitro-cleanup/history/route.ts @@ -0,0 +1,10 @@ +import { apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; +import { readAutoCleanHistory } from "@/lib/services/nitro-cleanup"; + +/** Auto-clean history (manual + nightly scheduled runs), newest first. */ +export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { + const history = await readAutoCleanHistory(); + return apiOk({ history }); +}); diff --git a/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts b/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts index 702b5565..cde021fd 100644 --- a/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts +++ b/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts @@ -5,6 +5,7 @@ import { logAudit } from "@/lib/services/audit"; import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; import { type NitroRepairProgress, + refreshScanCacheAfterMutation, repairBrokenNitros, } from "@/lib/services/nitro-cleanup"; @@ -54,7 +55,15 @@ export const POST = withAdmin( repaired: number; failed: number; errors: string[]; + files: Array<{ fileName: string; ok: boolean }>; }) => { + // Repaired bundles are no longer broken — drop them from the cached + // scan (names/digests unchanged, so the cache otherwise stays valid). + await refreshScanCacheAfterMutation( + "nitro", + result.files.filter((file) => file.ok).map((file) => file.fileName), + 0, + ); clearFurniImportCache(); if (result.repaired > 0) { logAudit({ diff --git a/src/app/api/admin/studio/nitro-cleanup/route.ts b/src/app/api/admin/studio/nitro-cleanup/route.ts index 8d5c5436..cb72d6a5 100644 --- a/src/app/api/admin/studio/nitro-cleanup/route.ts +++ b/src/app/api/admin/studio/nitro-cleanup/route.ts @@ -4,14 +4,18 @@ import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; import { + type AssetCleanupEntry, ageDays, type CleanupAssetKind, - type CleanupProgress, deleteNitroCleanupFiles, - scanFakeBrokenNitros, + getPersistedCleanupResult, + refreshScanCacheAfterMutation, } from "@/lib/services/nitro-cleanup"; const VALID_KINDS: readonly CleanupAssetKind[] = ["nitro", "swf", "icon"]; +const GROUP_ORDER = ["fake", "broken", "orphanedSwf", "orphanedIcon"] as const; +type GroupKey = (typeof GROUP_ORDER)[number]; +const CHUNK_SIZE = 400; function intParam(value: string | null): number | null { if (value === null) return null; @@ -19,19 +23,26 @@ function intParam(value: string | null): number | null { return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null; } +function ageFilter( + entries: T[], + minAgeDays: number | null, +): T[] { + return minAgeDays !== null && minAgeDays > 0 + ? entries.filter((entry) => ageDays(entry.lastModified) >= minAgeDays) + : entries; +} + /** - * Scan all asset directories for fake (orphaned) nitro / swf / icon files and - * broken (unparseable) .nitro bundles. + * Serve the persisted scan result (written by the background scan started + * through POST `/run`). + * + * With `Accept: text/event-stream` the lists are streamed in bounded chunks + * (`summary` + `chunk` events) so a result with hundreds of thousands of + * entries is delivered without one giant JSON payload. With a plain Accept the + * full result is returned as JSON, which is also how the manifest export works. * * Query params: - * - `force=1` skips the on-disk scan cache (fresh re-validation); - * - `minAgeDays=N` only returns entries older than N days; - * - `limit=N` caps each returned list (use `full=1` to override); - * - `full=1` never applies the limit (used by the manifest export). - * - * When the client asks for `text/event-stream` the scan is streamed as - * `progress` / `result` / `error` events, keeping the connection alive while a - * large nitro directory is walked so reverse proxies don't time the request out. + * - `minAgeDays=N` (SSE + JSON) only returns entries older than N days. */ export const GET = withAdmin( { permission: PERMS.ASSETS_IMPORT }, @@ -39,77 +50,101 @@ export const GET = withAdmin( const wantsSse = request.headers .get("accept") ?.includes("text/event-stream"); - const force = request.nextUrl.searchParams.get("force") === "1"; - const full = request.nextUrl.searchParams.get("full") === "1"; const minAgeDays = intParam(request.nextUrl.searchParams.get("minAgeDays")); - const limit = intParam(request.nextUrl.searchParams.get("limit")); - - const run = async (onProgress?: (p: CleanupProgress) => void) => { - const scan = await scanFakeBrokenNitros({ force, onProgress }); - const ageFilter = ( - entries: T[], - ): T[] => - minAgeDays !== null && minAgeDays > 0 - ? entries.filter((entry) => ageDays(entry.lastModified) >= minAgeDays) - : entries; - const cap = (entries: T[]): T[] => - full || limit === null || limit <= 0 - ? entries - : entries.slice(0, limit); - return { - fake: cap(ageFilter(scan.fake)), - broken: cap(ageFilter(scan.broken)), - orphanedSwf: cap(ageFilter(scan.orphanedSwf)), - orphanedIcon: cap(ageFilter(scan.orphanedIcon)), - total: scan.total, - cached: scan.cached ?? false, - }; - }; - try { - if (!wantsSse) { - return apiOk(await run()); - } - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - const send = (data: unknown) => { + const persisted = await getPersistedCleanupResult(); + if (wantsSse) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const send = (data: unknown) => { + try { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(data)}\n\n`), + ); + } catch { + /* stream closed by client */ + } + }; try { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(data)}\n\n`), - ); - } catch { - /* stream closed by client */ + if (!persisted) { + send({ + type: "error", + message: + "No scan result yet — run a furniture asset scan first.", + }); + } else { + const groups: Partial> = { + fake: ageFilter(persisted.fake, minAgeDays), + broken: ageFilter(persisted.broken, minAgeDays), + orphanedSwf: ageFilter(persisted.orphanedSwf, minAgeDays), + orphanedIcon: ageFilter(persisted.orphanedIcon, minAgeDays), + }; + send({ + type: "summary", + total: persisted.total, + cached: persisted.cached ?? false, + minAgeDays: minAgeDays ?? 0, + groups: Object.fromEntries( + Object.entries(groups).map(([key, value]) => [ + key, + (value ?? []).length, + ]), + ), + }); + for (const group of GROUP_ORDER) { + const entries = groups[group] ?? []; + for ( + let offset = 0; + offset < entries.length; + offset += CHUNK_SIZE + ) { + send({ + type: "chunk", + group, + offset, + entries: entries.slice(offset, offset + CHUNK_SIZE), + }); + } + } + } + } catch (err) { + send({ + type: "error", + message: `Failed to read scan result: ${(err as Error).message}`, + }); } - }; - try { - const result = await run((progress) => - send({ type: "progress", ...progress }), - ); - send({ type: "result", ...result }); - } catch (err) { - send({ - type: "error", - message: `Asset scan failed: ${(err as Error).message}`, - }); - } - try { - controller.close(); - } catch { - /* ignore */ - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, + try { + controller.close(); + } catch { + /* ignore */ + } + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + if (!persisted) { + return apiError( + "No scan result yet — run a furniture asset scan first.", + 404, + ); + } + return apiOk({ + ...persisted, + minAgeDays: minAgeDays ?? 0, + cached: persisted.cached ?? false, }); } catch (err) { - return apiError(`Asset scan failed: ${(err as Error).message}`, 500); + return apiError( + `Failed to read scan result: ${(err as Error).message}`, + 500, + ); } }, ); @@ -142,8 +177,16 @@ export const DELETE = withAdmin( } try { 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. + // Keep the persisted scan and the furniture list consistent without + // re-scanning: drop the removed files from the scan cache and clear + // the furniture presence cache so the Studio reflects it immediately. + await refreshScanCacheAfterMutation( + kind, + result.files + .filter((file) => file.deleted) + .map((file) => file.fileName), + result.deleted, + ); clearFurniImportCache(); if (result.deleted > 0) { logAudit({ diff --git a/src/app/api/admin/studio/nitro-cleanup/run/route.ts b/src/app/api/admin/studio/nitro-cleanup/run/route.ts new file mode 100644 index 00000000..2ba59287 --- /dev/null +++ b/src/app/api/admin/studio/nitro-cleanup/run/route.ts @@ -0,0 +1,49 @@ +import { apiError, apiJson, apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; +import { logAudit } from "@/lib/services/audit"; +import { + NitroScanConflictError, + runScanInBackground, +} from "@/lib/services/nitro-scan-session"; + +/** + * Start (or re-attach to) the furniture asset scan in the background. The scan + * keeps running detached in the server process once this returns, so closing + * the tab does not lose it. Clients poll GET `/session` for progress and fetch + * the result through GET `/data`. + * + * @returns 200 with the started session, or 409 with `conflict: true` and the + * in-flight session when a fresh scan is already running (<= the 60s stale + * window) — the client should attach to that instead of starting a new one. + */ +export const POST = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (request, ctx) => { + let body: { force?: unknown }; + try { + body = await request.json(); + } catch { + body = {}; + } + const force = body?.force === true; + try { + const session = await runScanInBackground({ force }); + logAudit({ + userId: ctx.session.user.id, + action: "nitro_cleanup_scan", + target: "AssetFiles", + after: { force, sessionId: session.id }, + }); + return apiOk({ session }); + } catch (err) { + if (err instanceof NitroScanConflictError) { + return apiJson( + { ok: true, session: err.session, conflict: true }, + { status: 409 }, + ); + } + return apiError(`Failed to start scan: ${(err as Error).message}`, 500); + } + }, +); diff --git a/src/app/api/admin/studio/nitro-cleanup/session/route.ts b/src/app/api/admin/studio/nitro-cleanup/session/route.ts new file mode 100644 index 00000000..1c8eb3e8 --- /dev/null +++ b/src/app/api/admin/studio/nitro-cleanup/session/route.ts @@ -0,0 +1,14 @@ +import { apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; +import { readScanSession } from "@/lib/services/nitro-scan-session"; + +/** + * Current scan session (or null when no scan ran/stored yet). Polled by the + * Studio panel while a background scan is running so a closed-tab scan can be + * re-attached after a refresh. + */ +export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { + const session = await readScanSession(); + return apiOk({ session }); +}); diff --git a/src/components/admin/studio/nitro-cleanup-panel.tsx b/src/components/admin/studio/nitro-cleanup-panel.tsx index d41271cd..bd5ca5c3 100644 --- a/src/components/admin/studio/nitro-cleanup-panel.tsx +++ b/src/components/admin/studio/nitro-cleanup-panel.tsx @@ -4,6 +4,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { AlertTriangle, CheckCircle2, + Clock, Download, FileX2, Loader2, @@ -14,7 +15,8 @@ import { Wrench, X, } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Button } from "@/components/ui/button"; @@ -33,13 +35,27 @@ interface AssetEntry { error?: string; } -interface NitroCleanupScan { +type GroupKey = "fake" | "broken" | "orphanedSwf" | "orphanedIcon"; + +interface ScanData { fake: AssetEntry[]; broken: AssetEntry[]; orphanedSwf: AssetEntry[]; orphanedIcon: AssetEntry[]; total: number; + cached: boolean; +} + +interface ScanSession { + id: string; + state: "running" | "done" | "cancelled" | "error"; + startedAt: string; + updatedAt: string; + force?: boolean; + progress?: { phase: string; scanned: number }; cached?: boolean; + total?: number; + error?: string; } interface DeleteResponse { @@ -62,16 +78,30 @@ interface AutoCleanResponse { errors?: string[]; } +interface HistoryEntry { + triggeredAt: string; + triggeredBy: "manual" | "scheduled"; + maxAgeDays: number; + 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; -const SCAN_IDLE_TIMEOUT_MS = 120_000; +const SESSION_POLL_MS = 1500; +const DATA_IDLE_TIMEOUT_MS = 90_000; +const SESSION_URL = "/api/admin/studio/nitro-cleanup/session"; +const DATA_URL = "/api/admin/studio/nitro-cleanup"; -function ageLabel(lastModified: number): string { - if (!lastModified) return "age unknown"; +function ageLabel(t: (key: string) => string, lastModified: number): string { + if (!lastModified) return t("ageUnknown"); const days = Math.floor((Date.now() - lastModified) / DAY_MS); - if (days < 1) return "< 1d"; + if (days < 1) return t("ageBelow"); if (days < 30) return `${days}d`; if (days < 365) return `${Math.floor(days / 30)}mo`; return `${Math.floor(days / 365)}y`; @@ -99,11 +129,15 @@ function VirtualAssetList({ selected, disabled = false, onToggle, + copiesLabel, + formatAge, }: { entries: AssetEntry[]; selected: Set; disabled?: boolean; onToggle: (fileName: string) => void; + copiesLabel: string; + formatAge: (lastModified: number) => string; }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ @@ -147,7 +181,7 @@ function VirtualAssetList({ {formatBytes(entry.size)} - {ageLabel(entry.lastModified)} + {formatAge(entry.lastModified)} {entry.error && ( 1 && ( - {entry.dirs.length} copies + {copiesLabel.replace("{count}", String(entry.dirs.length))} )} @@ -179,6 +213,10 @@ function CleanupGroup({ disabled = false, onToggle, onToggleAll, + emptyLabel, + selectedLabel, + copiesLabel, + formatAge, }: { label: string; description: string; @@ -188,6 +226,10 @@ function CleanupGroup({ disabled?: boolean; onToggle: (fileName: string) => void; onToggleAll: (names: string[]) => void; + emptyLabel: string; + selectedLabel: string; + copiesLabel: string; + formatAge: (lastModified: number) => string; }) { const checked = entries.filter((e) => selected.has(e.fileName)); const allChecked = entries.length > 0 && checked.length === entries.length; @@ -223,7 +265,7 @@ function CleanupGroup({ {checked.length > 0 && ( - {checked.length} selected + {selectedLabel.replace("{count}", String(checked.length))} )} @@ -231,7 +273,7 @@ function CleanupGroup({ {entries.length === 0 ? (

- No {label.toLowerCase()} found + {emptyLabel}

) : ( )} @@ -246,124 +290,253 @@ function CleanupGroup({ } export function NitroCleanupPanel() { + const t = useTranslations("admin.studio.nitroCleanup"); + const formatAge = (lastModified: number): string => ageLabel(t, lastModified); + const [scanning, setScanning] = useState(false); const [deleting, setDeleting] = useState(false); const [repairing, setRepairing] = useState(false); const [autoCleaning, setAutoCleaning] = useState(false); const [exporting, setExporting] = useState(false); + const [loading, setLoading] = useState(false); const [autoAge, setAutoAge] = useState(30); const [minAgeDays, setMinAgeDays] = useState(0); const [onlyOld, setOnlyOld] = useState(false); - const [progress, setProgress] = useState<{ - phase: string; - scanned: number; - } | null>(null); + const [session, setSession] = useState(null); + const [attachedToExisting, setAttachedToExisting] = useState(false); + const [scan, setScan] = useState(null); + const [history, setHistory] = useState([]); + const [showHistory, setShowHistory] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [selectedSwf, setSelectedSwf] = useState>(new Set()); + const [selectedIcon, setSelectedIcon] = useState>(new Set()); const [repairProgress, setRepairProgress] = useState<{ completed: number; total: number; ok: number; failed: number; } | null>(null); - const [scan, setScan] = useState(null); - const [selected, setSelected] = useState>(new Set()); - const [selectedSwf, setSelectedSwf] = useState>(new Set()); - const [selectedIcon, setSelectedIcon] = useState>(new Set()); const abortRef = useRef(null); + const loadAbortRef = useRef(null); const { confirm, dialog } = useConfirmDialog(); + // Latest filter values, read from effects without putting them in deps. + const filterRef = useRef({ onlyOld, minAgeDays }); + useEffect(() => { + filterRef.current = { onlyOld, minAgeDays }; + }); - const scanNow = useCallback( - async (opts: { force?: boolean; minAgeDays?: number } = {}) => { - const force = opts.force ?? true; - const ageFilter = opts.minAgeDays ?? (onlyOld ? minAgeDays : 0); - abortRef.current?.abort(); - setScanning(true); - setProgress(null); - setSelected(new Set()); - setSelectedSwf(new Set()); - setSelectedIcon(new Set()); + const busy = scanning || deleting || repairing || autoCleaning || exporting; + const running = session?.state === "running" || (scanning && !session); - const abort = new AbortController(); - abortRef.current = abort; - const params = new URLSearchParams(); - if (force) params.set("force", "1"); - if (ageFilter > 0) params.set("minAgeDays", String(ageFilter)); + const fetchSession = useCallback(async (): Promise => { + const res = await adminFetch(SESSION_URL, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { session?: ScanSession | null }; + return data.session ?? null; + }, []); - const holder: { result: NitroCleanupScan | null } = { result: null }; - let lastError: string | null = null; - try { - const res = await adminFetch( - `/api/admin/studio/nitro-cleanup${ - params.size > 0 ? `?${params.toString()}` : "" - }`, - { headers: { Accept: "text/event-stream" }, signal: abort.signal }, + /** Assemble the persisted result from the chunked /data stream. */ + const loadData = useCallback(async (minAge: number): Promise => { + loadAbortRef.current?.abort(); + const abort = new AbortController(); + loadAbortRef.current = abort; + setLoading(true); + const params = new URLSearchParams(); + if (minAge > 0) params.set("minAgeDays", String(minAge)); + const holder: { + summary: { + total: number; + cached: boolean; + groups: Record; + } | null; + arrays: Partial>; + error: string | null; + } = { summary: null, arrays: {}, error: null }; + try { + const res = await adminFetch( + `${DATA_URL}${params.size > 0 ? `?${params.toString()}` : ""}`, + { + headers: { Accept: "text/event-stream" }, + signal: abort.signal, + credentials: "same-origin", + }, + ); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error( + typeof body?.error === "string" + ? body.error + : `Failed to load scan result (HTTP ${res.status})`, ); - if (!res.ok) { - const data = (await res.json().catch(() => null)) as { - error?: string; - } | null; + } + if (!res.body) throw new Error("No response stream"); + await readSseStream( + res.body, + (evt) => { + if (evt.type === "summary") { + holder.summary = { + total: Number(evt.total ?? 0), + cached: evt.cached === true, + groups: (evt.groups as Record) ?? { + fake: 0, + broken: 0, + orphanedSwf: 0, + orphanedIcon: 0, + }, + }; + for (const group of Object.keys( + holder.summary.groups, + ) as GroupKey[]) { + holder.arrays[group] = new Array( + holder.summary.groups[group] ?? 0, + ).fill(null); + } + } else if (evt.type === "chunk") { + const group = evt.group as GroupKey; + const offset = Number(evt.offset ?? 0); + const entries = (evt.entries ?? []) as AssetEntry[]; + const target = holder.arrays[group]; + if (target) { + for (let i = 0; i < entries.length; i++) { + target[offset + i] = entries[i]; + } + } + } else if (evt.type === "error") { + holder.error = String(evt.message ?? "Failed to load scan result"); + } + }, + abort.signal, + DATA_IDLE_TIMEOUT_MS, + ); + if (holder.error) throw new Error(holder.error); + if (abort.signal.aborted) return; + if (!holder.summary) throw new Error("No scan result yet"); + const arr = (g: GroupKey): AssetEntry[] => + (holder.arrays[g] ?? []) as AssetEntry[]; + setScan({ + fake: arr("fake"), + broken: arr("broken"), + orphanedSwf: arr("orphanedSwf"), + orphanedIcon: arr("orphanedIcon"), + total: holder.summary.total, + cached: holder.summary.cached, + }); + } catch (err) { + if ((err as Error).name !== "AbortError" && !abort.signal.aborted) { + toast.error( + err instanceof Error ? err.message : "Failed to load scan result", + ); + } + } finally { + setLoading(false); + if (loadAbortRef.current === abort) loadAbortRef.current = null; + } + }, []); + + /** Attach to whatever scan is currently persisted, restarting the poll + * when a new one begins in another tab. */ + useEffect(() => { + let active = true; + const attach = async () => { + const s = await fetchSession(); + if (active) setSession(s); + }; + void attach(); + return () => { + active = false; + }; + }, [fetchSession]); + + // Poll while a scan is running; the detached server process keeps updating + // the session file even if this exact tab reloaded between polls. + useEffect(() => { + if (session?.state !== "running") return; + const timer = setInterval(async () => { + const s = await fetchSession(); + if (s) setSession(s); + }, SESSION_POLL_MS); + return () => clearInterval(timer); + }, [session?.state, fetchSession]); + + // When the persisted session turns done, load the result (also covers the + // very first mount where a previous scan already completed). Filter changes + // re-fetch through the toggle handlers; the effect is idempotent while the + // session object stays the same, and running sessions no-op here. + useEffect(() => { + if (session?.state === "error" && session.error) { + toast.error(session.error); + } + if (session?.state === "done") { + const filter = filterRef.current; + void loadData(filter.onlyOld ? filter.minAgeDays : 0); + } + }, [session, loadData]); + + useEffect(() => { + const loadHistory = async () => { + const res = await adminFetch("/api/admin/studio/nitro-cleanup/history", { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return; + const data = (await res.json()) as { history?: HistoryEntry[] }; + setHistory(data.history ?? []); + }; + void loadHistory(); + }, []); + + const startScan = useCallback( + async (force: boolean) => { + if (busy || running) return; + setScanning(true); + setAttachedToExisting(false); + try { + const res = await adminFetch("/api/admin/studio/nitro-cleanup/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ force }), + }); + const data = (await res.json()) as { + session?: ScanSession; + conflict?: boolean; + error?: string; + }; + if (!res.ok || !data.session) { throw new Error( - typeof data?.error === "string" - ? data.error - : `Scan failed (HTTP ${res.status})`, + data.error || `Failed to start scan (HTTP ${res.status})`, ); } - if (!res.body) throw new Error("Scan returned no response body"); - - await readSseStream( - res.body, - (evt) => { - if (evt.type === "progress") { - setProgress({ - phase: String(evt.phase ?? ""), - scanned: Number(evt.scanned ?? 0), - }); - } else if (evt.type === "result") { - holder.result = { - fake: (evt.fake as AssetEntry[]) ?? [], - broken: (evt.broken as AssetEntry[]) ?? [], - orphanedSwf: (evt.orphanedSwf as AssetEntry[]) ?? [], - orphanedIcon: (evt.orphanedIcon as AssetEntry[]) ?? [], - total: Number(evt.total ?? 0), - cached: evt.cached === true, - }; - setScan(holder.result); - } else if (evt.type === "error") { - lastError = String(evt.message ?? "Scan failed"); - } - }, - abort.signal, - SCAN_IDLE_TIMEOUT_MS, - ); - - if (lastError) throw new Error(lastError); - const lastResult = holder.result; - if (lastResult) { - const found = - lastResult.fake.length + - lastResult.broken.length + - lastResult.orphanedSwf.length + - lastResult.orphanedIcon.length; - if (found === 0) { - toast.success("No orphaned or broken furniture assets found"); - } else { - toast.info(`Found ${found} removable asset file(s)`); - } + setSession(data.session); + setScan(null); + setSelected(new Set()); + setSelectedSwf(new Set()); + setSelectedIcon(new Set()); + if (data.conflict) { + setAttachedToExisting(true); + toast.info(t("alreadyRunning")); } } catch (err) { - if ((err as Error).name === "AbortError") { - toast.info("Scan cancelled"); - } else { - toast.error(err instanceof Error ? err.message : "Scan failed"); - } + toast.error( + err instanceof Error ? err.message : "Failed to start scan", + ); } finally { setScanning(false); - setProgress(null); - if (abortRef.current === abort) abortRef.current = null; } }, - [onlyOld, minAgeDays], + [busy, running, t], ); + const cancelScan = useCallback(async () => { + await adminFetch("/api/admin/studio/nitro-cleanup/cancel", { + method: "POST", + headers: { Accept: "application/json" }, + }); + setScanning(false); + }, []); + const toggle = (kind: AssetKind) => (fileName: string) => { const setter = kind === "swf" @@ -400,7 +573,7 @@ export function NitroCleanupPanel() { const totalSelected = selected.size + selectedSwf.size + selectedIcon.size; /** Drop deleted/attempted files from the loaded scan without re-scanning. */ - const dropFromScan = (kind: AssetKind, names: string[]) => { + const dropFromScan = (kind: AssetKind, names: string[], removed = 0) => { const remove = new Set(names); if (remove.size === 0) return; setScan((prev) => { @@ -414,6 +587,7 @@ export function NitroCleanupPanel() { orphanedSwf: kind === "swf" ? drop(prev.orphanedSwf) : prev.orphanedSwf, orphanedIcon: kind === "icon" ? drop(prev.orphanedIcon) : prev.orphanedIcon, + total: Math.max(0, prev.total - removed), }; }); }; @@ -430,10 +604,9 @@ export function NitroCleanupPanel() { } if (groups.length === 0) return; const confirmed = await confirm({ - title: `Delete ${totalSelected} asset file(s)?`, - description: - "The files will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", - confirmLabel: "Delete", + title: t("confirmDeleteTitle", { count: totalSelected }), + description: t("confirmDeleteDescription"), + confirmLabel: t("confirmDeleteLabel"), variant: "danger", }); if (!confirmed) return; @@ -443,7 +616,7 @@ export function NitroCleanupPanel() { let deleted = 0; let hadErrors = false; for (const group of groups) { - const res = await adminFetch("/api/admin/studio/nitro-cleanup", { + const res = await adminFetch(DATA_URL, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -458,16 +631,12 @@ export function NitroCleanupPanel() { deleted += data.deleted ?? 0; copiesRemoved += data.copiesRemoved ?? 0; if (data.errors?.length) hadErrors = true; - dropFromScan(group.kind, group.names); + dropFromScan(group.kind, group.names, data.deleted ?? 0); } if (hadErrors) { - toast.warning( - `Deleted ${copiesRemoved} copy/copies, but some files could not be removed`, - ); + toast.warning(t("toastDeletePartial", { copies: copiesRemoved })); } else { - toast.success( - `Deleted ${copiesRemoved} file copy/copies (${deleted} file(s))`, - ); + toast.success(t("toastDeleteDone", { copies: copiesRemoved, deleted })); } setSelected(new Set()); setSelectedSwf(new Set()); @@ -485,10 +654,9 @@ export function NitroCleanupPanel() { .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", + title: t("confirmRepairTitle", { count: fileNames.length }), + description: t("confirmRepairDescription"), + confirmLabel: t("confirmRepairLabel"), variant: "default", }); if (!confirmed) return; @@ -545,15 +713,15 @@ export function NitroCleanupPanel() { } }, abort.signal, - SCAN_IDLE_TIMEOUT_MS, + 120_000, ); if (lastError) throw new Error(lastError); const data = holder.result; if (data && data.repaired > 0) { - toast.success(`Repaired ${data.repaired} .nitro bundle(s)`); + toast.success(t("toastRepairDone", { count: data.repaired })); } else if (data) { - toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`); + toast.warning(t("toastRepairFailed", { failed: data.failed })); } // Only drop the attempted bundles when every one succeeded; partial // failures stay visible so they can be retried. @@ -563,7 +731,7 @@ export function NitroCleanupPanel() { setSelected(new Set()); } catch (err) { if ((err as Error).name === "AbortError") { - toast.info("Repair cancelled"); + toast.info(t("toastRepairCancelled")); } else { toast.error(err instanceof Error ? err.message : "Repair failed"); } @@ -577,10 +745,9 @@ export function NitroCleanupPanel() { 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", + title: t("confirmAutoCleanTitle", { days: autoAge }), + description: t("confirmAutoCleanDescription"), + confirmLabel: t("confirmAutoCleanLabel"), variant: "danger", }); if (!confirmed) return; @@ -600,7 +767,12 @@ export function NitroCleanupPanel() { 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.`, + t("toastAutoCleanDone", { + deleted: data.deleted, + copies: data.copiesRemoved, + recent: data.skippedRecent, + broken: data.skippedBroken, + }), ); const removable = (scan.fake ?? []) .filter( @@ -608,12 +780,20 @@ export function NitroCleanupPanel() { Math.floor((Date.now() - entry.lastModified) / DAY_MS) >= autoAge, ) .map((entry) => entry.fileName); - dropFromScan("nitro", removable); + dropFromScan("nitro", removable, data.deleted); } else { toast.info( - `Nothing old enough to remove (skipped ${data.skippedRecent} recent, ${data.skippedBroken} broken)`, + t("toastAutoCleanNothing", { + recent: data.skippedRecent, + broken: data.skippedBroken, + }), ); } + const hres = await adminFetch("/api/admin/studio/nitro-cleanup/history", { + headers: { Accept: "application/json" }, + }); + const hdata = (await hres.json()) as { history?: HistoryEntry[] }; + setHistory(hdata.history ?? []); } catch (err) { toast.error(err instanceof Error ? err.message : "Auto-clean failed"); } finally { @@ -625,10 +805,10 @@ export function NitroCleanupPanel() { if (scan === null) return; setExporting(true); try { - const res = await adminFetch("/api/admin/studio/nitro-cleanup?full=1", { + const res = await adminFetch(`${DATA_URL}?full=1`, { headers: { Accept: "application/json" }, }); - const data = (await res.json()) as NitroCleanupScan & { + const data = (await res.json()) as ScanData & { error?: string; }; if (!res.ok || data.error) { @@ -648,7 +828,7 @@ export function NitroCleanupPanel() { }, }; downloadJson(`nitro-cleanup-scan-${Date.now()}.json`, entries); - toast.success("Scan manifest downloaded"); + toast.success(t("toastManifestDownload")); } catch (err) { toast.error(err instanceof Error ? err.message : "Export failed"); } finally { @@ -658,57 +838,92 @@ export function NitroCleanupPanel() { const toggleOnlyOld = (value: boolean) => { setOnlyOld(value); - const threshold = value ? Math.max(0, minAgeDays) : undefined; - void scanNow({ force: false, minAgeDays: threshold ?? 0 }); + const threshold = value ? Math.max(0, minAgeDays) : 0; + if (session?.state === "done") void loadData(threshold); }; const changeMinAgeDays = (value: number) => { const days = Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; setMinAgeDays(days); - if (onlyOld) void scanNow({ force: false, minAgeDays: days }); + if (onlyOld && session?.state === "done") void loadData(days); }; - const busy = scanning || deleting || repairing || autoCleaning || exporting; const phaseLabel = (phase: string): string => phase === "stems" - ? "Loading furniture stems…" + ? t("phaseStems") : phase === "readdir" - ? "Reading asset directories…" + ? t("phaseReaddir") : phase === "validate" - ? "Validating .nitro bundles…" - : "Scanning…"; + ? t("phaseValidate") + : t("phaseScanning"); + + const groupProps = ( + key: GroupKey, + ): { + label: string; + description: string; + tone: GroupTone; + } => { + switch (key) { + case "fake": + return { + label: t("groupFakeLabel"), + description: t("groupFakeDescription"), + tone: "error", + }; + case "broken": + return { + label: t("groupBrokenLabel"), + description: t("groupBrokenDescription"), + tone: "warning", + }; + case "orphanedSwf": + return { + label: t("groupSwfLabel"), + description: t("groupSwfDescription"), + tone: "warning", + }; + default: + return { + label: t("groupIconLabel"), + description: t("groupIconDescription"), + tone: "neutral", + }; + } + }; return (
{dialog}
- {scanning || repairing ? ( + {running && ( - ) : null} + )} {scan && ( )} {scan && scan.broken.length > 0 && ( @@ -724,6 +941,7 @@ export function NitroCleanupPanel() { size="lg" disabled={ busy || + running || ![...selected].some((n) => scan.broken.some((e) => e.fileName === n), ) @@ -735,18 +953,18 @@ export function NitroCleanupPanel() { ) : ( )} - {repairing ? "Re-downloading…" : "Re-download broken"} + {repairing ? t("redownloading") : t("redownloadBroken")} )} {scan && scan.fake.length > 0 && ( <> -
setAutoAge(Number(e.target.value))} - aria-label="Minimum age in days for auto-clean" + aria-label={t("autoAgeLabel")} /> - days + {t("daysLabel")}
@@ -768,7 +986,7 @@ export function NitroCleanupPanel() { )}
- {scanning && progress && ( + {running && session?.progress && (
- {phaseLabel(progress.phase)} - {progress.scanned > 0 && ( + {phaseLabel(session.progress.phase)} + {session.progress.scanned > 0 && ( - {progress.scanned.toLocaleString()} files + {t("scanProgressFiles", { + count: session.progress.scanned, + })} + + )} + {attachedToExisting && ( + + {t("attachedNotice")} )}
@@ -796,31 +1021,42 @@ export function NitroCleanupPanel() { {repairing && repairProgress && (
- Re-downloading bundles… + {t("redownloading")} - {repairProgress.completed}/{repairProgress.total} — ok{" "} - {repairProgress.ok}, failed {repairProgress.failed} + {repairProgress.completed}/{repairProgress.total} —{" "} + {t("repairProgress", { + ok: repairProgress.ok, + failed: repairProgress.failed, + })}
)} + {session?.state === "cancelled" && ( +

+ {t("sessionCancelled")} +

+ )} + {scan && (
- Only show files older than + {t("onlyOldLabel")}
changeMinAgeDays(Number(e.target.value))} - aria-label="Minimum age in days to display" + aria-label={t("onlyOldAgeLabel")} /> - days + + {t("daysLabel")} +
+ {loading && ( +
+ + {t("loadingResult")} +
+ )} +
{scan.total}
- asset files scanned + {t("scannedStat")} {scan.cached && ( {" "} - (cached — directories unchanged) + {t("cachedNotice")} )}
@@ -852,7 +1096,7 @@ export function NitroCleanupPanel() { {scan.fake.length}
- Fake nitros (no matching item) + {t("fakeStat")}
@@ -860,48 +1104,56 @@ export function NitroCleanupPanel() { {scan.broken.length}
- Broken nitros (unparseable) + {t("brokenStat")}
- - - - + {( + [ + ["fake", selected], + ["broken", selected], + ["orphanedSwf", selectedSwf], + ["orphanedIcon", selectedIcon], + ] as Array<[GroupKey, Set]> + ).map(([key, set]) => { + const props = groupProps(key); + const entries = + key === "fake" + ? scan.fake + : key === "broken" + ? scan.broken + : key === "orphanedSwf" + ? scan.orphanedSwf + : scan.orphanedIcon; + return ( + + ); + })} {(scan.fake.length > 0 || scan.broken.length > 0) && (

@@ -909,30 +1161,97 @@ export function NitroCleanupPanel() { size={14} className="mt-0.5 shrink-0 text-[var(--admin-warning)]" /> - Deleting a broken .nitro leaves its item in the database without a - bundle — prefer re-downloading it, or let the catalog audit - re-obtain it later. Database rows are never removed by this tool. + {t("warningText")}

)}
- Only asset files are touched — FurnitureData.json and database rows - stay intact. + {t("onlyAssetsTouched")}
)} - {!scan && !scanning && ( + {!scan && !session && !running && !loading && (
-

- 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. -

+

{t("emptyHint")}

)} + + {!scan && session?.state === "done" && !loading && ( +
+

{t("noResult")}

+
+ )} + + {!scan && session?.state === "error" && !loading && ( +
+

{t("scanFailed")}

+ {session.error && ( +

+ {session.error} +

+ )} +
+ )} + + {history.length > 0 && ( +
+ + {showHistory && ( +
    + {history.map((entry) => ( +
  • + + {entry.triggeredBy === "scheduled" + ? t("historyScheduled") + : t("historyManual")} + + + {new Date(entry.triggeredAt).toLocaleString()} + + + {t("historyEntry", { + deleted: entry.deleted, + copies: entry.copiesRemoved, + recent: entry.skippedRecent, + broken: entry.skippedBroken, + })} + + {entry.errors.length > 0 && ( + + {t("historyErrors", { count: entry.errors.length })} + + )} +
  • + ))} +
+ )} +
+ )} ); } diff --git a/src/lib/services/nitro-cleanup.test.ts b/src/lib/services/nitro-cleanup.test.ts index 0f4fab21..85e75f90 100644 --- a/src/lib/services/nitro-cleanup.test.ts +++ b/src/lib/services/nitro-cleanup.test.ts @@ -88,10 +88,20 @@ vi.mock("node:fs", async (orig) => { import { autoCleanFakeNitros, deleteNitroCleanupFiles, + getPersistedCleanupResult, type NitroRepairProgress, + refreshScanCacheAfterMutation, repairBrokenNitros, scanFakeBrokenNitros, } from "@/lib/services/nitro-cleanup"; +import { + cancelActiveScan, + cancelScanSession, + NitroScanConflictError, + readScanSession, + runScanInBackground, + writeScanSession, +} from "@/lib/services/nitro-scan-session"; beforeEach(() => { vi.clearAllMocks(); @@ -128,6 +138,49 @@ beforeEach(() => { statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 })); }); +/** + * File-backed state for the mocked `node:fs` promises: cache/session/history + * writes land in the map and subsequent reads/exists checks consult it, while + * binary reads (nitro bundles) fall back to placeholder bytes. + */ +function installFileState( + initial: Record = {}, +): Map { + const files = new Map(Object.entries(initial)); + readFileFn.mockImplementation(async (filePath: string) => { + const stored = files.get(filePath); + if (stored !== undefined) return stored; + return Buffer.from("nitro-data"); + }); + writeFileFn.mockImplementation(async (filePath: string, data: unknown) => { + files.set( + filePath, + Buffer.isBuffer(data) ? data.toString("utf-8") : String(data), + ); + }); + existsFn.mockImplementation((filePath: string) => files.has(filePath)); + return files; +} + +async function waitForSessionState( + expected: string, + timeoutMs = 2000, +): Promise>> { + const deadline = Date.now() + timeoutMs; + let session = await readScanSession(); + while ((session?.state ?? null) !== expected && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + session = await readScanSession(); + } + return session; +} + +/** Let a detached scan from the previous test settle before the next one. */ +async function settleDetachedScans(): Promise { + cancelActiveScan(); + await new Promise((resolve) => setTimeout(resolve, 20)); +} + function mockReaddir(byDir: Record): void { readdirFn.mockImplementation(async (dir: string) => byDir[dir] ?? []); } @@ -429,6 +482,166 @@ describe("autoCleanFakeNitros", () => { }); }); +describe("refreshScanCacheAfterMutation", () => { + it("drops removed files and recomputes digests so the cache stays valid", async () => { + const dirs = { + "nitro\u0000/assets/nitro": dirSignature(["chair.nitro", "ghost.nitro"]), + "swf\u0000/assets/swf": dirSignature([]), + "icon\u0000/assets/icons": dirSignature([]), + }; + const files = installFileState({ + "/var/www/atom-nexst/storage/nitro-cleanup/scan-cache.json": + JSON.stringify({ + dirs, + result: { + fake: [ + { + fileName: "ghost.nitro", + base: "ghost", + size: 500, + dirs: ["/assets/nitro"], + lastModified: 0, + }, + ], + broken: [], + orphanedSwf: [], + orphanedIcon: [], + total: 2, + }, + }), + }); + // The directory changed after the cached scan (ghost.nitro deleted). + readdirFn.mockImplementation(async (dir: string) => + dir.endsWith("/nitro") ? ["chair.nitro"] : [], + ); + + await refreshScanCacheAfterMutation("nitro", ["ghost.nitro"], 1); + + const written = JSON.parse( + files.get( + "/var/www/atom-nexst/storage/nitro-cleanup/scan-cache.json", + ) as string, + ) as { + dirs: Record; + result: { + fake: Array<{ fileName: string }>; + broken: unknown[]; + orphanedSwf: unknown[]; + orphanedIcon: unknown[]; + total: number; + }; + }; + expect(written.dirs["nitro\u0000/assets/nitro"]).toBe( + dirSignature(["chair.nitro"]), + ); + expect(written.result.fake).toEqual([]); + expect(written.result.total).toBe(1); + // The persisted result is now the refreshed one. + await expect(getPersistedCleanupResult()).resolves.toMatchObject({ + fake: [], + total: 1, + }); + }); + + it("is a no-op when the file names list is empty or no cache exists", async () => { + installFileState({}); + await refreshScanCacheAfterMutation("nitro", [], 0); + installFileState({}); + await refreshScanCacheAfterMutation("nitro", ["ghost.nitro"], 1); + expect(writeFileFn).not.toHaveBeenCalled(); + }); +}); + +describe("scan aborting", () => { + it("throws an AbortError and skips the scan cache write when aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + scanFakeBrokenNitros({ signal: controller.signal }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(writeFileFn).not.toHaveBeenCalled(); + expect(readFileFn).not.toHaveBeenCalled(); + }); +}); + +describe("nitro-scan-session", () => { + it("runs a scan in the background and persists the done session", async () => { + await settleDetachedScans(); + installFileState({}); + readdirFn.mockImplementation(async (dir: string) => + dir.endsWith("/nitro") ? ["chair.nitro"] : [], + ); + parseNitroBundle.mockReturnValue({}); + statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 })); + + const session = await runScanInBackground({ force: true }); + + expect(session.id).toBeTruthy(); + const done = await waitForSessionState("done"); + expect(done?.id).toBe(session.id); + expect(done?.total).toBe(1); + expect(done?.cached).toBe(false); + }); + + it("rejects a second scan while a fresh one is running (single-flight)", async () => { + await settleDetachedScans(); + installFileState({}); + readdirFn.mockImplementation(async (dir: string) => + dir.endsWith("/nitro") ? ["chair.nitro"] : [], + ); + parseNitroBundle.mockReturnValue({}); + + await runScanInBackground({ force: false }); + await expect(runScanInBackground({ force: false })).rejects.toBeInstanceOf( + NitroScanConflictError, + ); + + await settleDetachedScans(); + }); + + it("lets a stale running session be taken over (crashed process recovery)", async () => { + await settleDetachedScans(); + installFileState({}); + readdirFn.mockImplementation(async (dir: string) => + dir.endsWith("/nitro") ? ["chair.nitro"] : [], + ); + parseNitroBundle.mockReturnValue({}); + const staleHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + await writeScanSession({ + id: "stale-session", + state: "running", + startedAt: staleHourAgo, + updatedAt: staleHourAgo, + force: false, + }); + + const session = await runScanInBackground({ force: false }); + + expect(session.id).not.toBe("stale-session"); + await expect(waitForSessionState("done")).resolves.toMatchObject({ + id: session.id, + }); + }); + + it("marks the session cancelled when the scan is aborted", async () => { + await settleDetachedScans(); + installFileState({}); + readdirFn.mockImplementation(async (dir: string) => + dir.endsWith("/nitro") ? ["chair.nitro"] : [], + ); + parseNitroBundle.mockReturnValue({}); + + const session = await runScanInBackground({ force: false }); + const cancelled = await cancelScanSession(); + + expect(cancelled?.state).toBe("cancelled"); + await expect(waitForSessionState("cancelled")).resolves.toMatchObject({ + id: session.id, + }); + }); +}); + describe("repairBrokenNitros", () => { it("re-downloads valid bundles and writes them to every nitro dir", async () => { getTargetsFn.mockResolvedValue({ diff --git a/src/lib/services/nitro-cleanup.ts b/src/lib/services/nitro-cleanup.ts index 329f75b5..963b1db2 100644 --- a/src/lib/services/nitro-cleanup.ts +++ b/src/lib/services/nitro-cleanup.ts @@ -74,6 +74,12 @@ export interface CleanupScanOptions { * leaving the tab looking frozen while a large directory is processed. */ onProgress?: (progress: CleanupProgress) => void; + /** + * Aborts the scan. Every CPU-bound stage (readdir, stat pool, header + * validation) checks the signal and throws an `AbortError`-named error, so a + * cancelled scan never writes a partial result to the on-disk cache. + */ + signal?: AbortSignal; } export interface NitroCleanupDeleteResult { @@ -130,6 +136,16 @@ const KIND_FILE_RE: Record = { const DAY_MS = 24 * 60 * 60 * 1000; +function abortSignalReason(): Error { + const error = new Error("Scan cancelled"); + error.name = "AbortError"; + return error; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw abortSignalReason(); +} + function uniqueDirs(dirs: string[]): string[] { const seen = new Set(); const out: string[] = []; @@ -249,9 +265,11 @@ const DIR_STAT_CONCURRENCY = 32; async function buildDirEntries( dir: string, names: string[], + signal?: AbortSignal, ): Promise { const entries: DirectoryRead[] = []; await runPool(names, DIR_STAT_CONCURRENCY, async (name) => { + throwIfAborted(signal); let size = 0; let mtimeMs = 0; try { @@ -275,11 +293,13 @@ async function collectAssets( kind: CleanupAssetKind, dirs: string[], namesByKey: Map, + signal?: AbortSignal, ): Promise> { const byName = new Map(); for (const dir of dirs) { + throwIfAborted(signal); const names = namesByKey.get(kindKey(kind, dir)) ?? []; - for (const read of await buildDirEntries(dir, names)) { + for (const read of await buildDirEntries(dir, names, signal)) { const entry = byName.get(read.name) ?? { fileName: read.name, base: read.base, @@ -355,7 +375,7 @@ function cacheDirsMatch( export async function scanFakeBrokenNitros( options: CleanupScanOptions = {}, ): Promise { - const { force = false, onProgress } = options; + const { force = false, onProgress, signal } = options; const targets = await getFurniAssetWriteTargets(); const nitroDirs = uniqueDirs([ targets.nitroDir, @@ -386,6 +406,7 @@ export async function scanFakeBrokenNitros( await Promise.all( directoryPlan.flatMap(({ kind, dirs }) => dirs.map(async (dir) => { + throwIfAborted(signal); namesByKey.set( kindKey(kind, dir), await readDirNames(dir, KIND_FILE_RE[kind]), @@ -398,6 +419,7 @@ export async function scanFakeBrokenNitros( currentDigests[key] = dirNameSignature(namesByKey.get(key) ?? []); } + throwIfAborted(signal); onProgress?.({ phase: "readdir", scanned: 0 }); if (!force) { const cache = await readScanCache(); @@ -407,14 +429,15 @@ export async function scanFakeBrokenNitros( } } + throwIfAborted(signal); // Needed only for classification — skip the DB query on a cache hit. onProgress?.({ phase: "stems", scanned: 0 }); const validStems = await getCleanupValidStems(); const [byNitro, bySwf, byIcon] = await Promise.all([ - collectAssets("nitro", nitroDirs, namesByKey), - collectAssets("swf", swfDirs, namesByKey), - collectAssets("icon", iconDirs, namesByKey), + collectAssets("nitro", nitroDirs, namesByKey, signal), + collectAssets("swf", swfDirs, namesByKey, signal), + collectAssets("icon", iconDirs, namesByKey, signal), ]); const entries: NitroCleanupEntry[] = [...byNitro.values()]; @@ -424,6 +447,7 @@ export async function scanFakeBrokenNitros( const totalToValidate = entries.length; await runPool(entries, 16, async (entry) => { + throwIfAborted(signal); // A .nitro that no DB item maps to is a leftover / fake bundle. if (!validStems.has(entry.base.toLowerCase())) { fake.push(entry); @@ -477,6 +501,7 @@ export async function scanFakeBrokenNitros( orphanedIcon: byName(orphanedIcon), total: byNitro.size + bySwf.size + byIcon.size, }; + throwIfAborted(signal); await writeScanCache(currentDigests, result); onProgress?.({ phase: "done", scanned: result.total }); return result; @@ -567,6 +592,147 @@ export async function autoCleanFakeNitros( }; } +export async function getPersistedCleanupResult(): Promise { + const cache = await readScanCache(); + return cache?.result ?? null; +} + +/** + * Keep the on-disk scan cache consistent after a mutation without re-scanning: + * recompute the directory signatures for the touched asset kind (cheap readdir + * pass — no stats, no header validation) and drop the removed file names from + * the cached result lists. Without this, the next non-forced scan would see a + * digest mismatch (e.g. after a delete) and re-validate the whole directory. + */ +export async function refreshScanCacheAfterMutation( + kind: CleanupAssetKind, + fileNames: string[], + removedCount = fileNames.length, +): Promise { + const remove = new Set(fileNames); + if (remove.size === 0) return; + const cache = await readScanCache(); + if (!cache) return; + + const targets = await getFurniAssetWriteTargets(); + for (const dir of dirsForKind(targets, kind)) { + cache.dirs[kindKey(kind, dir)] = dirNameSignature( + await readDirNames(dir, KIND_FILE_RE[kind]), + ); + } + + const result = cache.result; + if (kind === "nitro") { + result.fake = result.fake.filter((e) => !remove.has(e.fileName)); + result.broken = result.broken.filter((e) => !remove.has(e.fileName)); + } else if (kind === "swf") { + result.orphanedSwf = result.orphanedSwf.filter( + (e) => !remove.has(e.fileName), + ); + } else { + result.orphanedIcon = result.orphanedIcon.filter( + (e) => !remove.has(e.fileName), + ); + } + result.total = Math.max(0, result.total - removedCount); + await writeScanCache(cache.dirs, result); +} + +// ── Auto-clean history ────────────────────────────────────────────────────── +// +// The nightly scheduled auto-clean and the manual button both record a history +// entry so staff can see what the jobs worker removed and when. + +export interface NitroAutoCleanHistoryEntry { + triggeredAt: string; + triggeredBy: "manual" | "scheduled"; + maxAgeDays: number; + deleted: number; + copiesRemoved: number; + skippedRecent: number; + skippedBroken: number; + errors: string[]; +} + +const HISTORY_MAX_ENTRIES = 50; +const historyPath = () => path.join(SCAN_CACHE_DIR, "history.json"); + +export async function readAutoCleanHistory(): Promise< + NitroAutoCleanHistoryEntry[] +> { + try { + if (!existsSync(historyPath())) return []; + const parsed = JSON.parse(await fs.readFile(historyPath(), "utf8")); + if (!Array.isArray(parsed)) return []; + return parsed as NitroAutoCleanHistoryEntry[]; + } catch { + return []; + } +} + +export async function appendAutoCleanHistory( + entry: NitroAutoCleanHistoryEntry, +): Promise { + try { + const history = (await readAutoCleanHistory()).slice( + 0, + HISTORY_MAX_ENTRIES - 1, + ); + await fs.mkdir(SCAN_CACHE_DIR, { recursive: true }); + await fs.writeFile(historyPath(), JSON.stringify([entry, ...history])); + } catch { + /* best effort — history is never fatal */ + } +} + +/** + * Refresh the scan cache + append a history entry after an auto-clean run. + * Shared by the manual route and the nightly scheduled job. + */ +export async function finishAutoClean(options: { + result: NitroAutoCleanResult; + maxAgeDays: number; + triggeredBy: "manual" | "scheduled"; +}): Promise { + const removed = options.result.files + .filter((file) => file.deleted) + .map((file) => file.fileName); + await refreshScanCacheAfterMutation("nitro", removed, options.result.deleted); + await appendAutoCleanHistory({ + triggeredAt: new Date().toISOString(), + triggeredBy: options.triggeredBy, + maxAgeDays: options.maxAgeDays, + deleted: options.result.deleted, + copiesRemoved: options.result.copiesRemoved, + skippedRecent: options.result.skippedRecent, + skippedBroken: options.result.skippedBroken, + errors: options.result.errors, + }); +} + +/** + * Entry point for the nightly jobs-worker cron. Never runs while an interactive + * scan session is in flight, so scheduled deletion can't race the UI. + */ +export async function scheduledAutoCleanFakeNitros( + maxAgeDays = 30, +): Promise<{ skipped: boolean; deleted?: number }> { + const { isSessionStale, readScanSession } = await import( + "./nitro-scan-session" + ); + const session = await readScanSession(); + if (session?.state === "running" && !isSessionStale(session)) { + return { skipped: true }; + } + const result = await autoCleanFakeNitros(maxAgeDays); + await finishAutoClean({ + result, + maxAgeDays, + triggeredBy: "scheduled", + }); + return { skipped: false, deleted: result.deleted }; +} + async function copyFileToDirs( buffer: Buffer, fileName: string, diff --git a/src/lib/services/nitro-scan-session.ts b/src/lib/services/nitro-scan-session.ts new file mode 100644 index 00000000..2d82cd11 --- /dev/null +++ b/src/lib/services/nitro-scan-session.ts @@ -0,0 +1,225 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, promises as fs } from "node:fs"; +import path from "node:path"; +import { logger } from "@/lib/logger"; +import { + type CleanupProgress, + scanFakeBrokenNitros, +} from "@/lib/services/nitro-cleanup"; + +/** + * Durable nitro-cleanup scan session. + * + * A scan is expensive (hundreds of thousands of stat + header reads), so it is + * better run as a detached in-process job than blocked inside one request: + * closing the tab must not lose the scan and a second tab should re-attach + * instead of starting a duplicate. The session is persisted on disk so the + * Next.js process keeps working it after the POST that started it has already + * returned, and a restart mid-scan is recoverable — a "running" session whose + * heartbeat (`updatedAt`) has gone stale is treated as dead and taken over. + * + * The lightweight session record only stores metadata + progress; the actual + * result lists live in the scan cache (`scan-cache.json`) written by the scan, + * so a done session is read back without duplicating megabytes of JSON. + */ + +export const nitroScanRoot = () => + path.join(process.cwd(), "storage", "nitro-cleanup"); +const sessionPath = () => path.join(nitroScanRoot(), "session.json"); + +export type NitroScanState = "running" | "done" | "cancelled" | "error"; + +export interface NitroScanSession { + id: string; + state: NitroScanState; + startedAt: string; + updatedAt: string; + force: boolean; + progress?: { phase: string; scanned: number }; + cached?: boolean; + total?: number; + error?: string; +} + +/** A running session that has not been heard from for this long is stale. */ +export const SESSION_STALE_MS = 60_000; + +/** Thrown when a fresh scan session already exists (single-flight). */ +export class NitroScanConflictError extends Error { + readonly session: NitroScanSession; + constructor(session: NitroScanSession) { + super("A nitro scan is already running"); + this.name = "NitroScanConflictError"; + this.session = session; + } +} + +export async function readScanSession(): Promise { + try { + if (!existsSync(sessionPath())) return null; + const parsed = JSON.parse( + await fs.readFile(sessionPath(), "utf8"), + ) as NitroScanSession; + return parsed && typeof parsed.id === "string" ? parsed : null; + } catch { + return null; + } +} + +export async function writeScanSession( + session: NitroScanSession, +): Promise { + try { + await fs.mkdir(nitroScanRoot(), { recursive: true }); + await fs.writeFile(sessionPath(), JSON.stringify(session)); + } catch { + /* session file is best effort — never fatal */ + } +} + +export function isSessionStale(session: NitroScanSession): boolean { + const age = Date.now() - new Date(session.updatedAt).getTime(); + return !Number.isFinite(age) || age > SESSION_STALE_MS; +} + +/** + * Create a new scan session, or reject with `NitroScanConflictError` when a + * fresh session is already running (single-flight across the whole server). + */ +export async function startScanSession(options: { + force: boolean; +}): Promise { + const current = await readScanSession(); + if (current?.state === "running" && !isSessionStale(current)) { + throw new NitroScanConflictError(current); + } + const now = new Date().toISOString(); + const session: NitroScanSession = { + id: randomUUID(), + state: "running", + startedAt: now, + updatedAt: now, + force: options.force, + }; + await writeScanSession(session); + return session; +} + +interface ActiveScan { + sessionId: string; + controller: AbortController; + promise: Promise; +} + +let activeScan: ActiveScan | null = null; + +/** + * Start (or re-attach to) a scan in the background. Resolves with the session + * quickly; the scan itself keeps running detached in the process. Callers + * poll `readScanSession()` to follow progress. + * + * @throws {NitroScanConflictError} when a fresh (non-stale) scan is already + * running — the caller should attach to the returned session instead. + */ +export async function runScanInBackground(options: { + force: boolean; +}): Promise { + const session = await startScanSession(options); + const controller = new AbortController(); + + const sessionEvent = async (patch?: { + progress?: CleanupProgress; + }): Promise => { + await writeScanSession({ + ...session, + state: "running", + ...(patch?.progress ? { progress: patch.progress } : {}), + updatedAt: new Date().toISOString(), + }); + }; + + const run = (async () => { + try { + const scan = await scanFakeBrokenNitros({ + force: session.force, + signal: controller.signal, + onProgress: (progress) => void sessionEvent({ progress }), + }); + await writeScanSession({ + ...session, + state: "done", + cached: scan.cached ?? false, + total: scan.total, + progress: { phase: "done", scanned: scan.total }, + updatedAt: new Date().toISOString(), + }); + } catch (error) { + if ((error as Error).name === "AbortError") { + await writeScanSession({ + ...session, + state: "cancelled", + updatedAt: new Date().toISOString(), + }); + } else { + logger.error("Nitro scan failed", { + module: "nitro-cleanup-session", + sessionId: session.id, + error: error instanceof Error ? error.message : String(error), + }); + await writeScanSession({ + ...session, + state: "error", + error: error instanceof Error ? error.message : "Scan failed", + updatedAt: new Date().toISOString(), + }); + } + } finally { + if (activeScan?.sessionId === session.id) activeScan = null; + } + })(); + + activeScan = { sessionId: session.id, controller, promise: run }; + return session; +} + +/** Abort the in-process scan (if any). Returns whether one was aborted. */ +export function cancelActiveScan(): boolean { + if (!activeScan) return false; + activeScan.controller.abort(); + return true; +} + +/** Cancel the scan session regardless of who is running it. */ +export async function cancelScanSession(): Promise { + if (cancelActiveScan()) { + // The detached run observes the abort and marks the session cancelled — + // wait for that so the caller sees the definitive end state. + const deadline = Date.now() + 2000; + let session = await readScanSession(); + while (session?.state === "running" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + session = await readScanSession(); + } + if (session?.state === "running" && session) { + const cancelled: NitroScanSession = { + ...session, + state: "cancelled", + updatedAt: new Date().toISOString(), + }; + await writeScanSession(cancelled); + session = cancelled; + } + return session; + } + const session = await readScanSession(); + if (session?.state === "running") { + const cancelled: NitroScanSession = { + ...session, + state: "cancelled", + updatedAt: new Date().toISOString(), + }; + await writeScanSession(cancelled); + return cancelled; + } + return session; +} diff --git a/src/messages/en.json b/src/messages/en.json index 08cf22e9..fea89de3 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -6497,6 +6497,78 @@ }, "admin": { "studio": { + "nitroCleanup": { + "alreadyRunning": "A scan is already running — attached to it.", + "ageUnknown": "age unknown", + "ageBelow": "< 1d", + "scanButton": "Scan furniture assets", + "scanningAssets": "Scanning assets…", + "cancelButton": "Cancel", + "deleteSelected": "Delete selected ({count})", + "deleting": "Deleting…", + "redownloadBroken": "Re-download broken", + "redownloading": "Re-downloading…", + "autoClean": "Auto-clean old fakes", + "cleaning": "Cleaning…", + "exportManifest": "Export manifest", + "exporting": "Exporting…", + "phaseStems": "Loading furniture stems…", + "phaseReaddir": "Reading asset directories…", + "phaseValidate": "Validating .nitro bundles…", + "phaseScanning": "Scanning…", + "scanProgressFiles": "{count} files", + "repairProgress": "ok {ok}, failed {failed}", + "attachedNotice": "Scan continues in the background — results will appear when it finishes.", + "sessionCancelled": "Scan cancelled.", + "onlyOldLabel": "Only show files older than", + "onlyOldAgeLabel": "Minimum age in days to display", + "onlyOldToggle": "Hide recent files", + "daysLabel": "days", + "autoAgeLabel": "Minimum age in days for auto-clean", + "loadingResult": "Loading scan result…", + "scannedStat": "asset files scanned", + "cachedNotice": "(cached — directories unchanged)", + "fakeStat": "Fake nitros (no matching item)", + "brokenStat": "Broken nitros (unparseable)", + "groupEmpty": "None found", + "selectedCount": "{count} selected", + "copiesBadge": "{count} copies", + "groupFakeLabel": "Fake nitros", + "groupFakeDescription": "These files match no item in items_base — leftovers from deleted or renamed furniture.", + "groupBrokenLabel": "Broken nitros", + "groupBrokenDescription": "These files belong to an item but cannot be parsed as a valid Nitro bundle.", + "groupSwfLabel": "Orphaned SWFs", + "groupSwfDescription": ".swf files that match no item in items_base.", + "groupIconLabel": "Orphaned icons", + "groupIconDescription": "Icon files that match no item in items_base.", + "warningText": "Deleting a broken .nitro leaves its item in the database without a bundle — prefer re-downloading it, or let the catalog audit re-obtain it later. Database rows are never removed by this tool.", + "onlyAssetsTouched": "Only asset files are touched — FurnitureData.json and database rows stay intact.", + "emptyHint": "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.", + "noResult": "No scan result yet — run a furniture asset scan first.", + "scanFailed": "The scan failed.", + "confirmDeleteTitle": "Delete {count} asset file(s)?", + "confirmDeleteDescription": "The files will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", + "confirmDeleteLabel": "Delete", + "confirmRepairTitle": "Re-download {count} broken .nitro bundle(s)?", + "confirmRepairDescription": "The bundles will be fetched again from the configured nitro sources and written over every directory (public + mirrored gamedata).", + "confirmRepairLabel": "Re-download", + "confirmAutoCleanTitle": "Auto-clean fake nitros older than {days} day(s)?", + "confirmAutoCleanDescription": "Only fake .nitro files (no matching item) older than the threshold are removed. Broken bundles and recent files are never touched.", + "confirmAutoCleanLabel": "Auto-clean", + "toastDeletePartial": "Deleted {copies} copy/copies, but some files could not be removed", + "toastDeleteDone": "Deleted {copies} file copy/copies ({deleted} file(s))", + "toastRepairDone": "Repaired {count} .nitro bundle(s)", + "toastRepairFailed": "Could not re-obtain any bundle ({failed} failed)", + "toastRepairCancelled": "Repair cancelled", + "toastAutoCleanDone": "Auto-cleaned {deleted} old fake .nitro file(s) ({copies} copies). Skipped {recent} recent + {broken} broken.", + "toastAutoCleanNothing": "Nothing old enough to remove (skipped {recent} recent, {broken} broken)", + "toastManifestDownload": "Scan manifest downloaded", + "historyTitle": "Auto-clean history", + "historyScheduled": "scheduled", + "historyManual": "manual", + "historyEntry": "{deleted} removed ({copies} copies); {recent} recent and {broken} broken skipped", + "historyErrors": "{count} errors" + }, "importDiagnostics": { "attempts": "Source file diagnostics", "preliminary": "Preliminary check: these addresses were checked without downloading the files. The import will validate the original files.", diff --git a/src/messages/nl.json b/src/messages/nl.json index 2f8dd625..2f510f9d 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -6503,6 +6503,78 @@ }, "admin": { "studio": { + "nitroCleanup": { + "alreadyRunning": "Er draait al een scan — de resultaten volgen als die klaar is.", + "ageUnknown": "leeftijd onbekend", + "ageBelow": "< 1d", + "scanButton": "Furniture assets scannen", + "scanningAssets": "Assets scannen…", + "cancelButton": "Annuleren", + "deleteSelected": "{count} geselecteerd verwijderen", + "deleting": "Verwijderen…", + "redownloadBroken": "Opnieuw downloaden", + "redownloading": "Opnieuw downloaden…", + "autoClean": "Oude fakes opschonen", + "cleaning": "Opschonen…", + "exportManifest": "Manifest exporteren", + "exporting": "Exporteren…", + "phaseStems": "Furniture stems laden…", + "phaseReaddir": "Asset-mappen lezen…", + "phaseValidate": ".nitro-bundles valideren…", + "phaseScanning": "Scannen…", + "scanProgressFiles": "{count} bestanden", + "repairProgress": "ok {ok}, mislukt {failed}", + "attachedNotice": "De scan draait op de achtergrond verder — resultaten verschijnen als die klaar is.", + "sessionCancelled": "Scan geannuleerd.", + "onlyOldLabel": "Alleen bestanden ouder dan", + "onlyOldAgeLabel": "Minimumleeftijd in dagen om te tonen", + "onlyOldToggle": "Recente bestanden verbergen", + "daysLabel": "dagen", + "autoAgeLabel": "Minimumleeftijd in dagen voor opruimen", + "loadingResult": "Scanresultaat laden…", + "scannedStat": "asset-bestanden gescand", + "cachedNotice": "(in cache — mappen ongewijzigd)", + "fakeStat": "Fake-nitros (geen overeenkomend item)", + "brokenStat": "Kapotte nitros (niet parseerbaar)", + "groupEmpty": "Niets gevonden", + "selectedCount": "{count} geselecteerd", + "copiesBadge": "{count} kopieën", + "groupFakeLabel": "Fake-nitros", + "groupFakeDescription": "Deze bestanden matchen geen enkel item in items_base — overblijfselen van verwijderde of hernoemde furniture.", + "groupBrokenLabel": "Kapotte nitros", + "groupBrokenDescription": "Deze bestanden horen bij een item maar kunnen niet als geldige Nitro-bundle worden gelezen.", + "groupSwfLabel": "Verweesde SWFs", + "groupSwfDescription": ".swf-bestanden die geen enkel item in items_base matchen.", + "groupIconLabel": "Verweesde iconen", + "groupIconDescription": "Icoonbestanden die geen enkel item in items_base matchen.", + "warningText": "Een kapotte .nitro verwijderen laat het item in de database achter zonder bundle — download het liever opnieuw, of laat de catalogaudit het later opnieuw ophalen. Database-rijen worden door deze tool nooit verwijderd.", + "onlyAssetsTouched": "Alleen asset-bestanden worden aangeraakt — FurnitureData.json en database-rijen blijven intact.", + "emptyHint": "Scan elke furni-assetmap om fake- en kapotte nitro-bundles plus verweesde SWFs en iconen te vinden. Verwijder ze, download de kapotte opnieuw of ruim oude overblijfselen automatisch op.", + "noResult": "Nog geen scanresultaat — start eerst een furniture-assetscan.", + "scanFailed": "De scan is mislukt.", + "confirmDeleteTitle": "{count} assetbestand(en) verwijderen?", + "confirmDeleteDescription": "De bestanden worden uit elke map verwijderd (publiek + gespiegelde gamedata). Database-rijen worden nooit aangeraakt.", + "confirmDeleteLabel": "Verwijderen", + "confirmRepairTitle": "{count} kapotte .nitro-bundles opnieuw downloaden?", + "confirmRepairDescription": "De bundles worden opnieuw opgehaald van de geconfigureerde nitro-bronnen en over elke map geschreven (publiek + gespiegelde gamedata).", + "confirmRepairLabel": "Opnieuw downloaden", + "confirmAutoCleanTitle": "Fake-nitros ouder dan {days} dag(en) automatisch opruimen?", + "confirmAutoCleanDescription": "Alleen fake .nitro-bestanden (geen overeenkomend item) ouder dan de drempel worden verwijderd. Kapotte bundles en recente bestanden worden nooit aangeraakt.", + "confirmAutoCleanLabel": "Opschonen", + "toastDeletePartial": "{copies} kopie(ën) verwijderd, maar sommige bestanden konden niet worden verwijderd", + "toastDeleteDone": "{copies} bestandskopie(ën) verwijderd ({deleted} bestand(en))", + "toastRepairDone": "{count} .nitro-bundle(s) gerepareerd", + "toastRepairFailed": "Geen enkele bundle kon worden opgehaald ({failed} mislukt)", + "toastRepairCancelled": "Herstel geannuleerd", + "toastAutoCleanDone": "{deleted} oude fake .nitro-bestand(en) opgeruimd ({copies} kopieën). {recent} recente en {broken} kapotte overgeslagen.", + "toastAutoCleanNothing": "Niets oud genoeg om te verwijderen ({recent} recente en {broken} kapotte overgeslagen)", + "toastManifestDownload": "Scanmanifest gedownload", + "historyTitle": "Opruimgeschiedenis", + "historyScheduled": "gepland", + "historyManual": "handmatig", + "historyEntry": "{deleted} verwijderd ({copies} kopieën); {recent} recente en {broken} kapotte overgeslagen", + "historyErrors": "{count} fouten" + }, "importDiagnostics": { "attempts": "Bestandsdiagnostiek", "preliminary": "Voorafgaande controle van adressen; inhoud wordt tijdens import gevalideerd.",