Files
EpicNext-Cms/src/lib/services/repair-icons.ts
T
openhands 90107c83d5
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m38s
fix: rewrite repair-icons with proper error handling and progress
- Wrap all DB/file operations in try-catch, send error events via SSE
- Report skipped/progress events for items that already have icons
- Add 'started' event with total count so the UI shows real-time progress
- Catch route-level errors and stream them instead of returning JSON
- Use log line content as React key instead of array index
2026-07-21 15:04:43 +02:00

163 lines
3.7 KiB
TypeScript

import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { listSources } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download";
import { ensureDirectories } from "./furni-import";
export interface RepairEvent {
type: "started" | "progress" | "done" | "batch_complete" | "error";
classname?: string;
status?: string;
index?: number;
total?: number;
message?: string;
warnings?: string[];
succeeded?: number;
failed?: number;
skipped?: number;
duration?: number;
}
export async function repairMissingIcons(
onEvent?: (evt: RepairEvent) => void,
): Promise<{ succeeded: number; failed: number; skipped: number }> {
let succeeded = 0;
let failed = 0;
let skipped = 0;
try {
await ensureDirectories();
} catch (err) {
onEvent?.({
type: "error",
message: `ensureDirectories failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
let iconDir: string;
let nitroDir: string;
try {
const dirs = await getFurniAssetDirs();
iconDir = dirs.iconDir;
nitroDir = dirs.nitroDir;
} catch (err) {
onEvent?.({
type: "error",
message: `getFurniAssetDirs failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
let items: Array<{ id: number; item_name: string }>;
try {
items = await prisma.$queryRaw<
Array<{ id: number; item_name: string }>
>`SELECT id, item_name FROM items_base ORDER BY id`;
} catch (err) {
onEvent?.({
type: "error",
message: `Database query failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
const total = items.length;
onEvent?.({ type: "started", total });
for (let i = 0; i < items.length; i++) {
const { item_name: classname } = items[i];
const iconPath = path.join(iconDir, `${classname}_icon.png`);
if (existsSync(iconPath)) {
skipped++;
onEvent?.({
type: "progress",
classname,
status: "skipped",
index: i,
total,
});
continue;
}
let ok = false;
const warnings: string[] = [];
const nitroPath = path.join(nitroDir, `${classname}.nitro`);
if (existsSync(nitroPath)) {
try {
const nitroBuf = await fs.readFile(nitroPath);
const icon = extractFurniIconPng(nitroBuf);
if (icon) {
await fs.writeFile(iconPath, icon);
ok = true;
} else {
warnings.push("no icon asset in .nitro bundle");
}
} catch (err) {
warnings.push(`extract from .nitro failed: ${(err as Error).message}`);
}
} else {
warnings.push(".nitro file not found on disk");
}
if (!ok) {
try {
const sources = await listSources();
for (const source of sources) {
try {
const dl = await downloadFile(
`${source.iconBaseUrl}/${classname}_icon.png`,
iconPath,
{ maxRetries: 1, validate: "png" },
);
if (dl.ok) {
ok = true;
break;
}
} catch {
warnings.push(`download from ${source.name} failed`);
}
}
} catch (err) {
warnings.push(`listSources failed: ${(err as Error).message}`);
}
}
if (ok) {
succeeded++;
onEvent?.({
type: "progress",
classname,
status: "done",
index: i,
total,
warnings: warnings.length ? warnings : undefined,
});
} else {
failed++;
onEvent?.({
type: "progress",
classname,
status: "failed",
index: i,
total,
message: warnings[0] || "could not obtain icon",
});
}
}
onEvent?.({
type: "batch_complete",
succeeded,
failed,
skipped,
});
return { succeeded, failed, skipped };
}