diff --git a/src/app/admin/import/repair-icons/repair-icons-client.tsx b/src/app/admin/import/repair-icons/repair-icons-client.tsx index 08e7ba9876..1c479dce8c 100644 --- a/src/app/admin/import/repair-icons/repair-icons-client.tsx +++ b/src/app/admin/import/repair-icons/repair-icons-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { ImageIcon, Loader2, Wrench } from "lucide-react"; +import { Check, ImageIcon, Loader2, Wrench } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -10,8 +10,13 @@ interface RepairEvent { type: string; classname?: string; status?: string; + index?: number; + total?: number; succeeded?: number; failed?: number; + skipped?: number; + message?: string; + warnings?: string[]; } export function RepairIconsClient() { @@ -24,6 +29,7 @@ export function RepairIconsClient() { const [result, setResult] = useState<{ succeeded: number; failed: number; + skipped: number; } | null>(null); const startRepair = useCallback(async () => { @@ -46,9 +52,10 @@ export function RepairIconsClient() { const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; + let total = 0; let succeeded = 0; let failed = 0; - let totalItems = 0; + let skipped = 0; while (true) { const { value, done: streamDone } = await reader.read(); @@ -60,22 +67,47 @@ export function RepairIconsClient() { if (!part.startsWith("data: ")) continue; try { const evt = JSON.parse(part.slice(6)) as RepairEvent; - if ( - evt.type === "item_progress" && - (evt.status === "done" || evt.status === "failed") - ) { - totalItems++; - if (evt.status === "done") succeeded++; - else failed++; - setProgress({ done: succeeded + failed, total: totalItems }); + + if (evt.type === "started") { + total = evt.total ?? 0; + setProgress({ done: 0, total }); + setLog((prev) => [...prev, `Scanning ${total} furniture items…`]); + } + + if (evt.type === "progress") { + const processed = (evt.index ?? 0) + 1; + setProgress({ done: processed, total }); + if (evt.status === "skipped") { + skipped++; + } else if (evt.status === "done") { + succeeded++; + } else if (evt.status === "failed") { + failed++; + } + const icon = + evt.status === "done" + ? "✓" + : evt.status === "failed" + ? "✗" + : "–"; setLog((prev) => [ ...prev, - `${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`, + `${icon} ${evt.classname ?? ""} (${evt.status})`, ]); } + if (evt.type === "batch_complete") { succeeded = evt.succeeded ?? succeeded; failed = evt.failed ?? failed; + skipped = evt.skipped ?? skipped; + } + + if (evt.type === "error") { + setLog((prev) => [ + ...prev, + `ERROR: ${evt.message ?? "Unknown error"}`, + ]); + toast.error(evt.message ?? "Repair failed"); } } catch { // skip parse errors @@ -83,8 +115,8 @@ export function RepairIconsClient() { } } - setResult({ succeeded, failed }); - if (succeeded > 0) toast.success(`${succeeded} icons repaired`); + setResult({ succeeded, failed, skipped }); + if (succeeded > 0) toast.success(`${succeeded} icons downloaded`); if (failed > 0) toast.error(`${failed} icons could not be obtained`); } catch { toast.error("Repair request failed"); @@ -110,8 +142,7 @@ export function RepairIconsClient() { {progress && (
- Processed {progress.done} items - {progress.total > 0 ? ` out of ${progress.total}` : ""} + Processed {progress.done} / {progress.total} items
)} @@ -128,19 +159,34 @@ export function RepairIconsClient() { {result && (
- + Repair Complete

- Icons downloaded:{" "} + Downloads:{" "} {result.succeeded}

+

+ + Skipped (already had icon): + {" "} + {result.skipped} +

Failed:{" "} {result.failed}

+
)} diff --git a/src/app/api/admin/import/furni/repair-icons/route.ts b/src/app/api/admin/import/furni/repair-icons/route.ts index 50cb55da81..c12d65c3fb 100644 --- a/src/app/api/admin/import/furni/repair-icons/route.ts +++ b/src/app/api/admin/import/furni/repair-icons/route.ts @@ -2,6 +2,10 @@ import { withAdmin } from "@/lib/api-handler"; import { PERMS } from "@/lib/permissions"; import { repairMissingIcons } from "@/lib/services/repair-icons"; +function sseEvent(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { const encoder = new TextEncoder(); @@ -9,17 +13,26 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { async start(controller) { const send = (data: unknown) => { try { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(data)}\n\n`), - ); + controller.enqueue(encoder.encode(sseEvent(data))); } catch { /* stream closed */ } }; - await repairMissingIcons(send); + try { + await repairMissingIcons(send); + } catch (err) { + send({ + type: "error", + message: `repairMissingIcons threw: ${(err as Error).message}`, + }); + } - controller.close(); + try { + controller.close(); + } catch { + /* ignore */ + } }, }); diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index a8ce417172..fc11f1039a 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -8,52 +8,81 @@ import { downloadFile } from "@/lib/services/import/core/download"; import { ensureDirectories } from "./furni-import"; export interface RepairEvent { - type: "item_progress" | "batch_complete" | "error"; + 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 }> { - await ensureDirectories(); - const { iconDir, nitroDir } = await getFurniAssetDirs(); - - const items = await prisma.$queryRaw< - Array<{ id: number; item_name: string }> - >`SELECT id, item_name FROM items_base ORDER BY id`; - - if (items.length === 0) { - onEvent?.({ - type: "batch_complete", - succeeded: 0, - failed: 0, - duration: 0, - }); - return { succeeded: 0, failed: 0 }; - } - +): 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)) continue; - - onEvent?.({ - type: "item_progress", - classname, - status: "started", - index: i, - }); + if (existsSync(iconPath)) { + skipped++; + onEvent?.({ + type: "progress", + classname, + status: "skipped", + index: i, + total, + }); + continue; + } let ok = false; const warnings: string[] = []; @@ -77,40 +106,46 @@ export async function repairMissingIcons( } if (!ok) { - 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; + 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 { - warnings.push(`download from ${source.name} failed`); } + } catch (err) { + warnings.push(`listSources failed: ${(err as Error).message}`); } } if (ok) { succeeded++; onEvent?.({ - type: "item_progress", + type: "progress", classname, status: "done", index: i, + total, warnings: warnings.length ? warnings : undefined, }); } else { failed++; onEvent?.({ - type: "item_progress", + type: "progress", classname, status: "failed", index: i, + total, message: warnings[0] || "could not obtain icon", }); } @@ -120,7 +155,8 @@ export async function repairMissingIcons( type: "batch_complete", succeeded, failed, + skipped, }); - return { succeeded, failed }; + return { succeeded, failed, skipped }; }