feat(nitro-cleanup): dedupe FurnitureData and clean dangling figure entries
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m16s
CI / tests-ui (push) Successful in 3m8s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m30s

Add a gamedata cleanup to the Nitro Cleanup panel: a read-only preview
plus an apply run that dedupes FurnitureData classnames and removes
rows and figure entries that reference nothing.

Three passes run in a fixed order, because cleanFigureMap has to precede
cleanFigureData: dropping the part that points at a set is what makes
that set unreferenced.

A pass refuses to write when it would delete more than maxRemovals rows
(default 500) and reports the reason, a wrong asset directory otherwise
turns every row into an orphan and one call would empty the file. Passes
that would act on empty input (no libraries, no sets) treat that as a
missing file rather than as a reason to delete everything. Every write
copies the file to a timestamped backup first, so a pass that turns out
to be wrong can be undone by hand.

The plan reads FurnitureData once and hands the parsed copy to both
furniture passes; the file is tens of megabytes in a real deployment.
This commit is contained in:
openhands committed 2026-09-27 17:14:30 +02:00
1 parent d176fad4da
commit 944527e078
7 files changed
+2163 -1

No files matched your search

@@ -58,6 +58,50 @@ interface ScanSession {
error?: string;
}
/**
* The gamedata cleanup reports its own shape instead of importing the scan
* module, which pulls in `node:fs` and cannot be bundled for the browser.
*/
interface GamedataPlan {
duplicates: Array<{
classname: string;
section: string;
removeIndexes: number[];
keepIndex: number;
hasNitro: boolean;
hasIcon: boolean;
differsFromSurvivor: boolean;
}>;
crossSection: Array<{ classname: string; sections: string[] }>;
orphans: Array<{ classname: string; section: string }>;
figureMapIssues: Array<{
libraryId: string;
reason: string;
partId: number | null;
partType: string;
}>;
figureDataIssues: Array<{
reason: string;
id: number;
setType?: string;
}>;
warnings: string[];
}
type GamedataPass =
| "dedupeFurnitureData"
| "cleanFurnitureData"
| "cleanFigureMap"
| "cleanFigureData";
/** Display order also documents that the passes are not interchangeable. */
const GAMEDATA_PASSES: GamedataPass[] = [
"dedupeFurnitureData",
"cleanFurnitureData",
"cleanFigureMap",
"cleanFigureData",
];
interface DeleteResponse {
deleted: number;
copiesRemoved: number;
@@ -318,6 +362,19 @@ export function NitroCleanupPanel() {
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const loadAbortRef = useRef<AbortController | null>(null);
const [gamedataPlan, setGamedataPlan] = useState<GamedataPlan | null>(null);
const [gamedataLoading, setGamedataLoading] = useState(false);
const [gamedataRunning, setGamedataRunning] = useState(false);
const [gamedataPasses, setGamedataPasses] = useState<Set<GamedataPass>>(
new Set(GAMEDATA_PASSES),
);
const [gamedataMaxRemovals, setGamedataMaxRemovals] = useState(0);
const [gamedataLimitError, setGamedataLimitError] = useState<{
pass: string;
wouldRemove: number;
maxRemovals: number;
partial: Record<string, Record<string, number>>;
} | null>(null);
const { confirm, dialog } = useConfirmDialog();
// Latest filter values, read from effects without putting them in deps.
const filterRef = useRef({ onlyOld, minAgeDays });
@@ -325,7 +382,13 @@ export function NitroCleanupPanel() {
filterRef.current = { onlyOld, minAgeDays };
});
const busy = scanning || deleting || repairing || autoCleaning || exporting;
const busy =
scanning ||
deleting ||
repairing ||
autoCleaning ||
exporting ||
gamedataRunning;
const running = session?.state === "running" || (scanning && !session);
const fetchSession = useCallback(async (): Promise<ScanSession | null> => {
@@ -801,6 +864,116 @@ export function NitroCleanupPanel() {
}
};
const loadGamedataPlan =
useCallback(async (): Promise<GamedataPlan | null> => {
setGamedataLoading(true);
try {
const res = await adminFetch(
"/api/admin/studio/nitro-cleanup/gamedata",
{ headers: { Accept: "application/json" } },
);
const data = (await res.json()) as {
plan?: GamedataPlan;
error?: string;
};
if (!res.ok) throw new Error(data.error || "Preview failed");
setGamedataPlan(data.plan ?? null);
return data.plan ?? null;
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Gamedata preview failed",
);
return null;
} finally {
setGamedataLoading(false);
}
}, []);
const toggleGamedataPass = (pass: GamedataPass) => {
setGamedataPasses((prev) => {
const next = new Set(prev);
if (next.has(pass)) next.delete(pass);
else next.add(pass);
return next;
});
};
const runGamedataCleanup = async (maxRemovals?: number) => {
const plan = gamedataPlan ?? (await loadGamedataPlan());
if (!plan) return;
if (gamedataPasses.size === 0) {
toast.error(t("gamedataNothingSelected"));
return;
}
const confirmed = await confirm({
title: t("gamedataConfirmTitle"),
description: t("gamedataConfirmDescription", {
duplicates: plan.duplicates.length,
orphans: plan.orphans.length,
figureMap: plan.figureMapIssues.length,
figureData: plan.figureDataIssues.length,
passes: gamedataPasses.size,
}),
confirmLabel: t("gamedataConfirmLabel"),
variant: "danger",
});
if (!confirmed) return;
setGamedataRunning(true);
setGamedataLimitError(null);
try {
const res = await adminFetch(
"/api/admin/studio/nitro-cleanup/gamedata/run",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
confirm: true,
passes: [...gamedataPasses],
...(maxRemovals === undefined ? {} : { maxRemovals }),
}),
},
);
const data = (await res.json()) as {
result?: Record<string, Record<string, number>>;
error?: string;
pass?: string;
wouldRemove?: number;
maxRemovals?: number;
partial?: Record<string, Record<string, number>>;
};
if (res.status === 409) {
// A pass refused to write. Nothing was written by that pass, so
// the files are as they were; the operator decides whether the
// limit was wrong or the paths are.
setGamedataLimitError({
pass: data.pass ?? "",
wouldRemove: data.wouldRemove ?? 0,
maxRemovals: data.maxRemovals ?? 0,
partial: data.partial ?? {},
});
toast.error(data.error || "Gamedata cleanup refused");
await loadGamedataPlan();
return;
}
if (!res.ok) throw new Error(data.error || "Gamedata cleanup failed");
const removed = Object.values(data.result ?? {}).reduce(
(total, counts) =>
total + Object.values(counts).reduce((sum, n) => sum + n, 0),
0,
);
if (removed > 0) toast.success(t("gamedataToastDone", { removed }));
else toast.info(t("gamedataToastNothing"));
// Re-read: the plan is derived from files this just rewrote.
await loadGamedataPlan();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Gamedata cleanup failed",
);
} finally {
setGamedataRunning(false);
}
};
const exportManifest = async () => {
if (scan === null) return;
setExporting(true);
@@ -1001,6 +1174,176 @@ export function NitroCleanupPanel() {
</p>
)}
<div className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="w-4 h-4 shrink-0" />
{t("gamedataTitle")}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={gamedataLoading || busy}
onClick={() => loadGamedataPlan()}
>
{gamedataLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<ScanSearch className="w-4 h-4 mr-2" />
)}
{gamedataLoading ? t("gamedataScanning") : t("gamedataScan")}
</Button>
<Button
size="sm"
disabled={busy || gamedataPlan === null}
onClick={() => {
void runGamedataCleanup();
}}
>
{gamedataRunning ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{gamedataRunning ? t("gamedataRunning") : t("gamedataRun")}
</Button>
</div>
</div>
<p className="text-xs text-[var(--admin-text-muted)]">
{t("gamedataHint")}
</p>
{gamedataPlan === null ? (
<p className="text-xs text-[var(--admin-text-muted)]">
{t("gamedataNoResult")}
</p>
) : (
<>
<div className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
{(
[
["duplicates", gamedataPlan.duplicates.length],
["orphans", gamedataPlan.orphans.length],
["figureMap", gamedataPlan.figureMapIssues.length],
["figureData", gamedataPlan.figureDataIssues.length],
] as const
).map(([key, count]) => (
<div
key={key}
className="rounded border border-[var(--admin-border)] px-2 py-1.5"
>
<div className="text-[var(--admin-text-muted)]">
{t(`gamedataCount_${key}`)}
</div>
<div className="font-mono text-sm font-medium">{count}</div>
</div>
))}
</div>
<div className="space-y-1.5">
{GAMEDATA_PASSES.map((pass) => (
<div key={pass} className="flex items-start gap-2 text-xs">
<Checkbox
id={`gamedata-pass-${pass}`}
checked={gamedataPasses.has(pass)}
disabled={busy}
onCheckedChange={() => toggleGamedataPass(pass)}
className="mt-0.5"
/>
<label
htmlFor={`gamedata-pass-${pass}`}
className="cursor-pointer"
>
<span className="font-medium">
{t(`gamedataPass_${pass}`)}
</span>
<span className="block text-[var(--admin-text-muted)]">
{t(`gamedataPassDesc_${pass}`)}
</span>
</label>
</div>
))}
</div>
{gamedataPlan.duplicates.some((d) => d.differsFromSurvivor) && (
<p className="flex items-start gap-1.5 text-xs text-[var(--admin-text-muted)]">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
{t("gamedataDifferingWarning")}
</p>
)}
{gamedataPlan.crossSection.length > 0 && (
<p className="text-xs text-[var(--admin-text-muted)]">
{t("gamedataCrossSectionNote", {
count: gamedataPlan.crossSection.length,
})}
</p>
)}
{gamedataPlan.warnings.map((warning) => (
<p
key={warning}
className="flex items-start gap-1.5 text-xs text-[var(--admin-text-muted)]"
>
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
{warning}
</p>
))}
<div className="flex flex-wrap items-center gap-2 rounded border border-[var(--admin-border)] bg-[var(--admin-surface)] p-2">
<label
htmlFor="gamedata-max-removals"
className="flex items-center gap-1.5 text-xs text-[var(--admin-text-muted)]"
>
{t("gamedataMaxRemovalsLabel")}
</label>
<Input
id="gamedata-max-removals"
type="number"
min={0}
className="w-24"
value={gamedataMaxRemovals}
disabled={busy}
onChange={(e) => setGamedataMaxRemovals(Number(e.target.value))}
aria-label={t("gamedataMaxRemovalsLabel")}
/>
<span className="text-xs text-[var(--admin-text-muted)]">
{t("gamedataMaxRemovalsHint")}
</span>
</div>
{gamedataLimitError && (
<div className="space-y-2 rounded border border-[var(--admin-error)]/40 bg-[var(--admin-error)]/5 p-2 text-xs">
<p className="font-medium text-[var(--admin-error)]">
{t("gamedataLimitTitle", {
pass: gamedataLimitError.pass,
wouldRemove: gamedataLimitError.wouldRemove,
maxRemovals: gamedataLimitError.maxRemovals,
})}
</p>
<p className="text-[var(--admin-text-muted)]">
{t("gamedataLimitDescription")}
</p>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => {
runGamedataCleanup(
Math.max(gamedataLimitError.wouldRemove, 1),
);
}}
>
{t("gamedataLimitOverride", {
wouldRemove: gamedataLimitError.wouldRemove,
})}
</Button>
</div>
)}
</>
)}
</div>
{scan && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">