fix(admin): harden clothing/pets/effects/clone imports

Align grids on data.items, only treat SSE done as success, hard-fail
clothing sets when libs fail, and add Cancel via AbortController.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-08-01 15:56:38 +02:00
1 parent 9c4949186c
commit 16191cef14
8 files changed
+344 -173

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -336,7 +336,7 @@ export function ImportFurniClient() {
setError(data.error || "Failed to load"); setError(data.error || "Failed to load");
return; return;
} }
const loadedItems = data.item || []; const loadedItems = data.items || [];
setItems(loadedItems); setItems(loadedItems);
setMeta(data.meta || null); setMeta(data.meta || null);
setSelected(new Set()); setSelected(new Set());
File diff suppressed because it is too large. Load diff
@@ -87,4 +87,20 @@ describe("importClothingSet", () => {
const r = await importClothingSet({ setType: "ch", setId: 999999 }); const r = await importClothingSet({ setType: "ch", setId: 999999 });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
it("does not write set data when a required library import fails", async () => {
mergeSet.mockClear();
mergePalette.mockClear();
importSingleFigure.mockResolvedValueOnce({
ok: false,
lib: "shirt_M_x",
warnings: [],
error: "nitro missing",
});
const r = await importClothingSet({ setType: "ch", setId: 5000 });
expect(r.ok).toBe(false);
expect(r.error).toMatch(/required figure libraries failed/i);
expect(mergeSet).not.toHaveBeenCalled();
expect(mergePalette).not.toHaveBeenCalled();
});
}); });
+15
View File
@@ -133,6 +133,8 @@ export async function importClothingSet(params: {
await ensureFigureDirs(); await ensureFigureDirs();
let hardFail = false;
for (const libId of requiredLibIds) { for (const libId of requiredLibIds) {
const officialLib = officialLibs.find((l) => l.id === libId); const officialLib = officialLibs.find((l) => l.id === libId);
if (!officialLib) continue; if (!officialLib) continue;
@@ -141,18 +143,31 @@ export async function importClothingSet(params: {
onProgress?.(`importing library ${libId}`); onProgress?.(`importing library ${libId}`);
const result = await importSingleFigure({ lib: libId, onProgress }); const result = await importSingleFigure({ lib: libId, onProgress });
if (!result.ok) { if (!result.ok) {
hardFail = true;
warnings.push( warnings.push(
`Library import failed for ${libId}: ${result.error ?? "unknown error"}`, `Library import failed for ${libId}: ${result.error ?? "unknown error"}`,
); );
continue;
} }
if (result.warnings.length) { if (result.warnings.length) {
warnings.push(...result.warnings); warnings.push(...result.warnings);
} }
localLibIds.add(libId);
} }
await mergeLibrary(officialLib); await mergeLibrary(officialLib);
} }
if (hardFail) {
return {
ok: false,
setId,
warnings,
error:
"One or more required figure libraries failed to import — set was not written",
};
}
const mappedSet = mapOfficialSet(set); const mappedSet = mapOfficialSet(set);
await mergeSet(setType, st.paletteId, mappedSet as never); await mergeSet(setType, st.paletteId, mappedSet as never);
+19 -5
View File
@@ -50,6 +50,7 @@ export async function runSseImport(
onDone: (classname: string) => void, onDone: (classname: string) => void,
onComplete: (succeeded: number, failed: number) => void, onComplete: (succeeded: number, failed: number) => void,
signal?: AbortSignal, signal?: AbortSignal,
onFailed?: (classname: string, error?: string) => void,
): Promise<void> { ): Promise<void> {
const res = await adminFetch(url, { const res = await adminFetch(url, {
method: "POST", method: "POST",
@@ -57,8 +58,19 @@ export async function runSseImport(
body: JSON.stringify(body), body: JSON.stringify(body),
signal, signal,
}); });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
toast.error(
typeof data.error === "string"
? data.error
: `Import failed (${res.status})`,
);
onComplete(0, 0);
return;
}
if (!res.body) { if (!res.body) {
toast.error("No response stream"); toast.error("No response stream");
onComplete(0, 0);
return; return;
} }
@@ -68,11 +80,13 @@ export async function runSseImport(
await readSseStream( await readSseStream(
res.body, res.body,
(evt) => { (evt) => {
if ( if (evt.type === "item_progress") {
evt.type === "item_progress" && const classname = String(evt.classname ?? "");
(evt.status === "done" || evt.status === "failed") if (evt.status === "done") {
) { onDone(classname);
onDone(String(evt.classname ?? "")); } else if (evt.status === "failed") {
onFailed?.(classname, String(evt.error ?? ""));
}
} }
if (evt.type === "batch_complete") { if (evt.type === "batch_complete") {
succeeded = Number(evt.succeeded ?? 0); succeeded = Number(evt.succeeded ?? 0);