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
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:
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">
|
||||
|
||||
Reference in new issue
Block a user