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 };
}