From 49d204e85c407498f600764eb4cbf538431570d0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 17 Jul 2026 18:52:38 +0200 Subject: [PATCH] Remove import/repair feature and related API routes. Co-authored-by: Cursor --- src/app/admin/import/page.tsx | 7 - src/app/admin/import/repair/page.tsx | 12 - src/app/admin/import/repair/repair-client.tsx | 832 ------------------ .../api/admin/import/repair/audit/route.ts | 11 - src/app/api/admin/import/repair/route.ts | 135 --- src/lib/import-backend-contract.test.ts | 2 - src/lib/services/furni-import.ts | 2 +- src/lib/services/furni-repair.ts | 465 ---------- src/messages/en.json | 4 +- 9 files changed, 2 insertions(+), 1468 deletions(-) delete mode 100644 src/app/admin/import/repair/page.tsx delete mode 100644 src/app/admin/import/repair/repair-client.tsx delete mode 100644 src/app/api/admin/import/repair/audit/route.ts delete mode 100644 src/app/api/admin/import/repair/route.ts delete mode 100644 src/lib/services/furni-repair.ts diff --git a/src/app/admin/import/page.tsx b/src/app/admin/import/page.tsx index 35b071a5..5c745cc9 100644 --- a/src/app/admin/import/page.tsx +++ b/src/app/admin/import/page.tsx @@ -5,7 +5,6 @@ import { Dumbbell, Puzzle, Shirt, - Wrench, } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -56,12 +55,6 @@ export default async function ImportPage() { icon: Copy, description: t("cloneDescription"), }, - { - href: "/admin/import/repair", - label: t("repair"), - icon: Wrench, - description: t("repairDescription"), - }, ] as const; return ( diff --git a/src/app/admin/import/repair/page.tsx b/src/app/admin/import/repair/page.tsx deleted file mode 100644 index 27dbc772..00000000 --- a/src/app/admin/import/repair/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { redirect } from "next/navigation"; -import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { RepairClient } from "./repair-client"; - -export default async function RepairImportedPage() { - const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { - redirect("/admin"); - } - - return ; -} diff --git a/src/app/admin/import/repair/repair-client.tsx b/src/app/admin/import/repair/repair-client.tsx deleted file mode 100644 index 1ef19c3b..00000000 --- a/src/app/admin/import/repair/repair-client.tsx +++ /dev/null @@ -1,832 +0,0 @@ -"use client"; - -import { - AlertCircle, - CheckCircle2, - Loader2, - Play, - RefreshCw, - Search, - Trash2, - Wrench, - XCircle, -} from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { deleteImportedFurni } from "@/actions/import-furni"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -type MissingKind = "nitro" | "icon" | "both"; - -interface MissingItem { - id: number; - classname: string; - baseClassname: string; - revision: number; - type: "s" | "i"; - publicName: string; - hasSwf: boolean; - hasIcon: boolean; - hasNitro: boolean; - missing: MissingKind; - unrepairable: boolean; -} - -interface AuditSummary { - total: number; - missingBoth: number; - missingNitro: number; - missingIcon: number; - unrepairable: number; - items: MissingItem[]; -} - -type ItemStatus = "idle" | "working" | "done" | "partial" | "failed"; - -interface ItemProgress { - status: ItemStatus; - message?: string; - nitroRepaired?: boolean; - iconRepaired?: boolean; - unrepairable?: boolean; -} - -interface RepairSummary { - succeeded: number; - partial: number; - failed: number; - unrepairable: number; - duration: number; -} - -const TAB_FILTERS: Record boolean> = { - all: () => true, - both: (m) => m.missing === "both", - nitro: (m) => m.missing === "nitro", - icon: (m) => m.missing === "icon", - unrepairable: (m) => m.unrepairable, -}; - -export function RepairClient() { - const [summary, setSummary] = useState(null); - const [auditing, setAuditing] = useState(false); - const [repairing, setRepairing] = useState(false); - const [search, setSearch] = useState(""); - const [tab, setTab] = useState("all"); - const [selected, setSelected] = useState>(new Set()); - const [progress, setProgress] = useState>( - new Map(), - ); - const [lastSummary, setLastSummary] = useState(null); - const [confirmDeleteAll, setConfirmDeleteAll] = useState(false); - - // ── Audit ────────────────────────────────────────────────────────── - const runAudit = useCallback(async () => { - setAuditing(true); - setLastSummary(null); - try { - const res = await fetch("/api/admin/import/repair/audit", { - method: "POST", - }); - if (!res.ok) { - toast.error("Audit failed"); - return; - } - const data = await res.json(); - setSummary(data); - setSelected(new Set()); - setProgress(new Map()); - toast.success( - `Audit: ${data.items.length} item${data.items.length === 1 ? "" : "s"} need repair (${data.total} scanned)`, - ); - } catch { - toast.error("Audit network error"); - } finally { - setAuditing(false); - } - }, []); - - // ── Filters ──────────────────────────────────────────────────────── - const filtered = useMemo(() => { - if (!summary) return []; - const s = search.trim().toLowerCase(); - return summary.items.filter((it) => { - if (!TAB_FILTERS[tab](it)) return false; - if (!s) return true; - return ( - it.classname.toLowerCase().includes(s) || - it.publicName.toLowerCase().includes(s) || - String(it.id).includes(s) - ); - }); - }, [summary, search, tab]); - - // ── Selection ────────────────────────────────────────────────────── - const allSelected = - filtered.length > 0 && filtered.every((i) => selected.has(i.classname)); - const toggleAll = () => { - setSelected((prev) => { - if (allSelected) { - const next = new Set(prev); - for (const i of filtered) { - next.delete(i.classname); - } - return next; - } - const next = new Set(prev); - for (const i of filtered) { - next.add(i.classname); - } - return next; - }); - }; - const toggleOne = (cn: string) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(cn)) next.delete(cn); - else next.add(cn); - return next; - }); - }; - - // ── Repair ───────────────────────────────────────────────────────── - const runRepair = useCallback(async (classnames: string[]) => { - if (classnames.length === 0) return; - setRepairing(true); - setLastSummary(null); - - // Init progress for these items - setProgress((prev) => { - const next = new Map(prev); - for (const cn of classnames) { - next.set(cn, { status: "working" }); - } - return next; - }); - - try { - const res = await fetch("/api/admin/import/repair", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ classnames, concurrency: 6 }), - }); - if (!res.ok || !res.body) { - toast.error("Failed to start repair"); - setRepairing(false); - return; - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - try { - const event = JSON.parse(line.slice(6)); - if (event.type === "repair_progress") { - setProgress((prev) => { - const next = new Map(prev); - next.set(event.classname, { - status: event.status as ItemStatus, - message: event.error || (event.warnings?.[0] ?? undefined), - nitroRepaired: event.nitroRepaired, - iconRepaired: event.iconRepaired, - unrepairable: event.unrepairable, - }); - return next; - }); - // Promote newly-discovered unrepairable flags onto the audit list - if (event.unrepairable) { - setSummary((prev) => { - if (!prev) return prev; - const item = prev.items.find( - (i) => i.classname === event.classname, - ); - if (!item || item.unrepairable) return prev; - return { - ...prev, - unrepairable: prev.unrepairable + 1, - items: prev.items.map((i) => - i.classname === event.classname - ? { ...i, unrepairable: true } - : i, - ), - }; - }); - } - // Remove repaired items from the audit list on "done" - if (event.status === "done") { - setSummary((prev) => { - if (!prev) return prev; - const items = prev.items.filter( - (i) => i.classname !== event.classname, - ); - const diff = prev.items.length - items.length; - if (diff === 0) return prev; - const removed = prev.items.find( - (i) => i.classname === event.classname, - ); - return { - ...prev, - items, - missingBoth: - prev.missingBoth - (removed?.missing === "both" ? 1 : 0), - missingNitro: - prev.missingNitro - - (removed?.missing === "nitro" ? 1 : 0), - missingIcon: - prev.missingIcon - (removed?.missing === "icon" ? 1 : 0), - }; - }); - setSelected((prev) => { - const next = new Set(prev); - next.delete(event.classname); - return next; - }); - } - } else if (event.type === "repair_complete") { - setLastSummary({ - succeeded: event.succeeded, - partial: event.partial, - failed: event.failed, - unrepairable: event.unrepairable ?? 0, - duration: event.duration, - }); - } - } catch { - /* skip malformed */ - } - } - } - } catch { - toast.error("Connection lost during repair"); - } finally { - setRepairing(false); - } - }, []); - - // ── Delete unrepairable items ────────────────────────────────────── - const deleteSelected = useCallback(async () => { - const toDelete = Array.from(selected); - if (toDelete.length === 0) return; - setConfirmDeleteAll(false); - - let ok = 0; - let fail = 0; - for (const cn of toDelete) { - setProgress((prev) => { - const next = new Map(prev); - next.set(cn, { status: "working", message: "Deleting…" }); - return next; - }); - try { - const result = await deleteImportedFurni({ classname: cn }); - if (result.ok) { - ok++; - setSummary((prev) => { - if (!prev) return prev; - const removed = prev.items.find((i) => i.classname === cn); - if (!removed) return prev; - return { - ...prev, - total: prev.total - 1, - items: prev.items.filter((i) => i.classname !== cn), - missingBoth: - prev.missingBoth - (removed.missing === "both" ? 1 : 0), - missingNitro: - prev.missingNitro - (removed.missing === "nitro" ? 1 : 0), - missingIcon: - prev.missingIcon - (removed.missing === "icon" ? 1 : 0), - }; - }); - setSelected((prev) => { - const next = new Set(prev); - next.delete(cn); - return next; - }); - } else { - fail++; - setProgress((prev) => { - const next = new Map(prev); - next.set(cn, { status: "failed", message: "Delete failed" }); - return next; - }); - } - } catch { - fail++; - setProgress((prev) => { - const next = new Map(prev); - next.set(cn, { status: "failed", message: "Delete error" }); - return next; - }); - } - } - toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ""}`); - }, [selected]); - - // ── Render ───────────────────────────────────────────────────────── - - const selectedArray = Array.from(selected); - const selectedCount = selectedArray.length; - - return ( -
- {/* Header */} -
-
-

- Repair Imported Assets -

-

- Re-download missing{" "} - .nitro bundles and - icons for items already imported. Items with no public asset on - habbo.com can be deleted from here. -

-
-
- -
-
- - {/* Initial empty state */} - {!summary && !auditing && ( -
- -

- Click “Scan Assets” to audit imported furniture. -

-

- This checks every row in items_base against files on - disk. Scanning ~33k items takes a few seconds. -

-
- )} - - {/* Stats */} - {summary && ( -
- - - - - -
- )} - - {/* Batch repair summary */} - {lastSummary && ( -
- Last run: {lastSummary.succeeded} fixed - {lastSummary.partial > 0 && ( - <> - , {lastSummary.partial} partial - - )} - {lastSummary.failed > 0 && ( - <> - , {lastSummary.failed} failed - - )} - {lastSummary.unrepairable > 0 && ( - <> - , {lastSummary.unrepairable}{" "} - unrepairable - - )} -  · {(lastSummary.duration / 1000).toFixed(1)}s -
- )} - - {/* Table area */} - {summary && summary.items.length === 0 && ( -
- -

- All imported items have their assets on disk. -

-

- {summary.total} items scanned, nothing to repair. -

-
- )} - - {summary && summary.items.length > 0 && ( - <> - {/* Toolbar */} -
-
- - setSearch(e.target.value)} - placeholder="Filter by classname, name, or id…" - className="pl-8" - /> -
- - - - - - {summary && summary.unrepairable > 0 && ( - - )} -
- - {/* Tabs */} - setTab(v as keyof typeof TAB_FILTERS)} - > - - - All ({summary.items.length}) - - - Both ({summary.missingBoth}) - - - .nitro ({summary.missingNitro}) - - - Icon ({summary.missingIcon}) - - - Unrepairable ({summary.unrepairable}) - - - -
- - - - - - - ID - Classname - Rev - .nitro - Icon - SWF - Status - Actions - - - - {filtered.length === 0 && ( - - - No items match the current filter. - - - )} - {filtered.map((it) => ( - toggleOne(it.classname)} - progress={progress.get(it.classname)} - onRepair={() => runRepair([it.classname])} - disabled={repairing} - /> - ))} - -
-
-
-
- - )} - - - - - - Delete {selectedCount} imported item - {selectedCount === 1 ? "" : "s"}? - - - This removes the rows from items_base /{" "} - catalog_items, the entry in{" "} - FurnitureData.json, and any remaining asset files on - disk. Use this for items that have no public asset on habbo.com - and therefore cannot be repaired. This cannot be undone. - - - - - - - - -
- ); -} - -// ── Small cell helpers ──────────────────────────────────────────────── - -function StatCard({ - label, - value, - tone, - sub, -}: { - label: string; - value: number; - tone: "default" | "danger" | "warn" | "info"; - sub?: string; -}) { - const tones = { - default: "border-border", - danger: "border-destructive/40 bg-destructive/5", - warn: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]", - info: "border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5", - }; - return ( -
-
- {label} -
-
{value.toLocaleString()}
- {sub &&
{sub}
} -
- ); -} - -function FileBadge({ present }: { present: boolean }) { - return present ? ( - - On disk - - ) : ( - - Missing - - ); -} - -function StatusCell({ progress }: { progress?: ItemProgress }) { - if (!progress || progress.status === "idle") - return —; - if (progress.status === "working") { - return ( - - - {progress.message || "Working…"} - - ); - } - if (progress.status === "done") { - const parts = [ - progress.nitroRepaired ? ".nitro ✓" : null, - progress.iconRepaired ? "icon ✓" : null, - ].filter(Boolean); - return ( - - - {parts.length > 0 ? parts.join(" · ") : "Repaired"} - - ); - } - if (progress.status === "partial") { - return ( - - - Partial · {progress.message || "check logs"} - - ); - } - return ( - - - {progress.message || "Failed"} - - ); -} - -function ItemRow({ - item, - selected, - onToggle, - progress, - onRepair, - disabled, -}: { - item: MissingItem; - selected: boolean; - onToggle: () => void; - progress?: ItemProgress; - onRepair: () => void; - disabled: boolean; -}) { - const isUnrepairable = item.unrepairable || progress?.unrepairable; - return ( - - - - - {item.id} - -
- {item.classname} - {isUnrepairable && ( - - Unrepairable - - )} -
-
{item.publicName}
-
- {item.revision || "—"} - - - - - - - - - - - - - - - -
- ); -} diff --git a/src/app/api/admin/import/repair/audit/route.ts b/src/app/api/admin/import/repair/audit/route.ts deleted file mode 100644 index c11bef1c..00000000 --- a/src/app/api/admin/import/repair/audit/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { withAdmin } from "@/lib/api-handler"; -import { apiOk } from "@/lib/api-response"; -import { PERMS } from "@/lib/permissions"; -import { auditImportedAssets } from "@/lib/services/furni-repair"; - -// Audit is expensive (scans every imported item's files on disk). -// Using POST keeps it out of any cache layer that might front GET. -export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { - const summary = await auditImportedAssets(); - return apiOk(summary as unknown as Record); -}); diff --git a/src/app/api/admin/import/repair/route.ts b/src/app/api/admin/import/repair/route.ts deleted file mode 100644 index c6f902fe..00000000 --- a/src/app/api/admin/import/repair/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { withAdmin } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; -import { PERMS } from "@/lib/permissions"; -import { repairItem } from "@/lib/services/furni-repair"; -import { rcon } from "@/lib/services/rcon"; - -/** - * SSE batch repair route. - * Body: { classnames: string[], concurrency?: number } - * Streams one `data: {...}` event per item. - */ -export const POST = withAdmin( - { permission: PERMS.ASSETS_IMPORT }, - async (request) => { - const body = await request.json(); - const classnames: string[] = Array.isArray(body.classnames) - ? body.classnames - : []; - // Bumped default to 6 (cap 8). Since downloadFile no longer retries on - // deterministic 404s, misses complete in ~1 request instead of ~4 × 15s. - const concurrency = Math.min(Math.max(body.concurrency || 6, 1), 8); - - if (classnames.length === 0) return apiError("No classnames provided", 400); - - const encoder = new TextEncoder(); - - const stream = new ReadableStream({ - async start(controller) { - const send = (data: unknown) => { - try { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(data)}\n\n`), - ); - } catch { - /* stream closed */ - } - }; - - const startTime = Date.now(); - send({ type: "repair_start", total: classnames.length }); - - let succeeded = 0; - let failed = 0; - let partial = 0; - let unrepairable = 0; - - for (let i = 0; i < classnames.length; i += concurrency) { - const chunk = classnames.slice(i, i + concurrency); - - const promises = chunk.map(async (classname) => { - send({ type: "repair_progress", classname, status: "working" }); - try { - const result = await repairItem(classname); - if (result.unrepairable) unrepairable++; - if (result.ok) { - succeeded++; - send({ - type: "repair_progress", - classname, - status: "done", - nitroRepaired: result.nitroRepaired, - iconRepaired: result.iconRepaired, - warnings: result.warnings, - }); - } else { - // Counts as partial if at least one asset was fixed - if (result.iconRepaired || result.nitroRepaired) { - partial++; - send({ - type: "repair_progress", - classname, - status: "partial", - nitroRepaired: result.nitroRepaired, - iconRepaired: result.iconRepaired, - warnings: result.warnings, - error: result.error, - unrepairable: result.unrepairable, - }); - } else { - failed++; - send({ - type: "repair_progress", - classname, - status: "failed", - warnings: result.warnings, - error: - result.error || result.warnings[0] || "Unknown error", - unrepairable: result.unrepairable, - }); - } - } - } catch (err) { - failed++; - send({ - type: "repair_progress", - classname, - status: "failed", - error: (err as Error).message, - }); - } - }); - - await Promise.allSettled(promises); - } - - // RCON refresh so the emulator picks up any items_base changes - try { - await rcon.updateItems(); - await rcon.updateCatalog(); - } catch { - /* non-fatal */ - } - - send({ - type: "repair_complete", - succeeded, - partial, - failed, - unrepairable, - duration: Date.now() - startTime, - }); - - controller.close(); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - }, -); diff --git a/src/lib/import-backend-contract.test.ts b/src/lib/import-backend-contract.test.ts index 70552ee4..22acfaa3 100644 --- a/src/lib/import-backend-contract.test.ts +++ b/src/lib/import-backend-contract.test.ts @@ -21,8 +21,6 @@ const ROUTES = [ "pets", "pets/batch", "pets/icon", - "repair", - "repair/audit", ]; describe("admin import backend contract", () => { diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index d0e3f14d..2a88502d 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -10,7 +10,7 @@ import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache"; import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache"; // Re-export the download helpers now owned by the shared import core. -// Existing furni callers (and furni-repair) keep importing them from here. +// Existing furni callers keep importing them from here. export { downloadFile, validatePngBytes, diff --git a/src/lib/services/furni-repair.ts b/src/lib/services/furni-repair.ts deleted file mode 100644 index 15714c82..00000000 --- a/src/lib/services/furni-repair.ts +++ /dev/null @@ -1,465 +0,0 @@ -/** - * Furni Repair Service - * - * Audits and repairs missing assets (.nitro bundles + icon PNGs) for items - * that were imported but whose files were not downloaded successfully - * (network errors, habbo.com 404s, classname URL-encoding issues, etc.). - * - * Reuses the download/convert helpers from furni-import.ts. - */ - -import { existsSync, promises as fs } from "node:fs"; -import path from "node:path"; -import { prisma } from "@/lib/prisma"; -import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; -import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; -import { downloadFile, ensureDirectories } from "@/lib/services/furni-import"; -import { - getHabboItFurnidata, - lookupHabboIt, -} from "@/lib/services/habbo-furnidata-cache"; -import { - convertSwfToNitro, - extractIconFromSwf, -} from "@/lib/services/swf-to-nitro"; - -// ── Types ──────────────────────────────────────────────────────────── - -export type MissingKind = "nitro" | "icon" | "both"; - -export interface MissingItem { - id: number; - classname: string; - baseClassname: string; - revision: number; - type: "s" | "i"; - publicName: string; - hasSwf: boolean; - hasIcon: boolean; - hasNitro: boolean; - missing: MissingKind; - /** Pre-flagged as impossible to repair from habbo.com (whitespace, invalid chars). */ - unrepairable: boolean; -} - -export interface AuditSummary { - total: number; - missingBoth: number; - missingNitro: number; - missingIcon: number; - unrepairable: number; - items: MissingItem[]; -} - -export interface RepairResult { - classname: string; - ok: boolean; - iconRepaired: boolean; - nitroRepaired: boolean; - warnings: string[]; - error?: string; - /** True when habbo.com will never serve this asset (bad classname or 404 across all known revisions). */ - unrepairable?: boolean; -} - -/** - * Checks whether a classname has a shape habbo.com can never serve. - * Currently: whitespace or control chars (habbo.com only uses [A-Za-z0-9_*.-]). - */ -export function isStructurallyImpossible(classname: string): boolean { - // Whitespace or ASCII control chars — habbo.com URL would 404. - if (/\s/.test(classname)) return true; - for (let i = 0; i < classname.length; i++) { - const code = classname.charCodeAt(i); - if (code < 0x20) return true; - } - return false; -} - -// ── Audit ──────────────────────────────────────────────────────────── - -/** - * Scans every row in `items_base` and reports which ones are missing - * their `.nitro` bundle and/or icon PNG on disk. - */ -export async function auditImportedAssets(): Promise { - const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs(); - const rows = await prisma.itemsBase.findMany({ - select: { - id: true, - itemName: true, - publicName: true, - type: true, - }, - }); - - const items: MissingItem[] = []; - let missingBoth = 0; - let missingNitro = 0; - let missingIcon = 0; - let unrepairable = 0; - - for (const row of rows) { - const classname = row.itemName; - if (!classname) continue; - - const starIdx = classname.indexOf("*"); - const baseClassname = - starIdx !== -1 ? classname.substring(0, starIdx) : classname; - const iconFileName = classname.replace(/\*/g, "_"); - - const nitroPath = path.join( - /*turbopackIgnore: true*/ nitroDir, - `${baseClassname}.nitro`, - ); - const swfPath = path.join( - /*turbopackIgnore: true*/ swfDir, - `${baseClassname}.swf`, - ); - const iconPath = path.join( - /*turbopackIgnore: true*/ iconDir, - `${iconFileName}_icon.png`, - ); - - const hasNitro = existsSync(/*turbopackIgnore: true*/ nitroPath); - const hasIcon = existsSync(/*turbopackIgnore: true*/ iconPath); - const hasSwf = existsSync(/*turbopackIgnore: true*/ swfPath); - - if (hasNitro && hasIcon) continue; - - let missing: MissingKind; - if (!hasNitro && !hasIcon) { - missing = "both"; - missingBoth++; - } else if (!hasNitro) { - missing = "nitro"; - missingNitro++; - } else { - missing = "icon"; - missingIcon++; - } - - const unrep = isStructurallyImpossible(classname); - if (unrep) unrepairable++; - - items.push({ - id: row.id, - classname, - baseClassname, - revision: 0, - type: (row.type === "i" ? "i" : "s") as "s" | "i", - publicName: row.publicName ?? classname, - hasSwf, - hasIcon, - hasNitro, - missing, - unrepairable: unrep, - }); - } - - // Stable sort: missing both first, then nitro, then icon; alphabetical within group. - // Unrepairable items sink to the bottom of their group so fixable ones surface first. - const order: Record = { both: 0, nitro: 1, icon: 2 }; - items.sort( - (a, b) => - order[a.missing] - order[b.missing] || - Number(a.unrepairable) - Number(b.unrepairable) || - a.classname.localeCompare(b.classname), - ); - - return { - total: rows.length, - missingBoth, - missingNitro, - missingIcon, - unrepairable, - items, - }; -} - -// ── Repair ─────────────────────────────────────────────────────────── - -/** - * Attempts to restore missing assets for a single item. - * - * Strategy: - * 1. Resolve candidate revisions: habbo.it furnidata, DB.revision, DB.spriteId. - * 2. If `.nitro` is missing and SWF is missing: try each revision → download SWF. - * 3. If SWF now exists: convert → write `.nitro`. - * 4. If icon PNG is missing: download from habbo.com; on failure, extract from SWF. - * 5. If `.nitro` was (re)generated, update items_base dims / interaction flags. - */ -export async function repairItem(classname: string): Promise { - const warnings: string[] = []; - await ensureDirectories(); - const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs(); - - const row = await prisma.itemsBase.findFirst({ - where: { itemName: classname }, - select: { - id: true, - spriteId: true, - type: true, - publicName: true, - }, - }); - - if (!row) { - return { - classname, - ok: false, - iconRepaired: false, - nitroRepaired: false, - warnings, - error: "Item not found in DB", - }; - } - - // Fast path: shapes habbo.com can never serve (whitespace, control chars). - // Skip every network call — safe us ~60s of useless 15s-timeout retries. - if (isStructurallyImpossible(classname)) { - return { - classname, - ok: false, - iconRepaired: false, - nitroRepaired: false, - warnings: [ - "Classname contains whitespace / control chars — habbo.com URL would 404", - ], - error: "Unrepairable classname", - unrepairable: true, - }; - } - - const starIdx = classname.indexOf("*"); - const baseClassname = - starIdx !== -1 ? classname.substring(0, starIdx) : classname; - const iconFileName = classname.replace(/\*/g, "_"); - - const nitroPath = path.join( - /*turbopackIgnore: true*/ nitroDir, - `${baseClassname}.nitro`, - ); - const swfPath = path.join( - /*turbopackIgnore: true*/ swfDir, - `${baseClassname}.swf`, - ); - const iconPath = path.join( - /*turbopackIgnore: true*/ iconDir, - `${iconFileName}_icon.png`, - ); - - const needsNitro = !existsSync(/*turbopackIgnore: true*/ nitroPath); - const needsIcon = !existsSync(/*turbopackIgnore: true*/ iconPath); - - if (!needsNitro && !needsIcon) { - return { - classname, - ok: true, - iconRepaired: false, - nitroRepaired: false, - warnings: ["Nothing to repair"], - }; - } - - // ── Resolve candidate revisions ───────────────────────────────────── - // Priority order: - // 1. habbo.it furnidata — authoritative for current live assets - // 2. items_base.revision (what we originally recorded at import) - // 3. items_base.spriteId (some emulators reuse sprite id as revision) - const revsToTry: number[] = []; - const pushRev = (r?: number | null) => { - if (r && r > 0 && !revsToTry.includes(r)) revsToTry.push(r); - }; - - try { - const furnidata = await getHabboItFurnidata(); - const fd = furnidata.get(classname) || furnidata.get(baseClassname); - if (fd) pushRev(fd.revision); - } catch { - // non-fatal - } - - pushRev(row.spriteId); - - // Fallback: revision 0 is valid for many habbo.com paths - if (revsToTry.length === 0) revsToTry.push(0); - - // ── Ensure SWF exists ─────────────────────────────────────────────── - // We also download the SWF when only the icon is missing, so we can - // fall back to SWF-embedded icon extraction when habbo.com 404s the PNG. - let swfOk = existsSync(swfPath); - const wantSwf = needsNitro || needsIcon; - if (wantSwf && !swfOk) { - for (const tryRev of revsToTry) { - const dl = await downloadFile( - `https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`, - swfPath, - { validate: "swf" }, - ); - if (dl.ok) { - swfOk = true; - break; - } - } - if (!swfOk && needsNitro) { - warnings.push( - `SWF not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`, - ); - } - } - - // ── Regenerate .nitro from SWF ────────────────────────────────────── - let nitroRepaired = false; - if (needsNitro && swfOk) { - try { - const swfBuffer = await fs.readFile(swfPath); - const conversion = convertSwfToNitro(swfBuffer, baseClassname); - await fs.writeFile(nitroPath, conversion.bundle); - nitroRepaired = true; - - // Sync dims + interaction flags back to items_base (best effort) - const dims = conversion.dimensions || { x: 1, y: 1, z: 0 }; - const meta = conversion.metadata || null; - const cansiton = meta?.cansiton ?? false; - const canlayon = meta?.canlayon ?? false; - const canstandon = meta?.canstandon ?? false; - const allowStack = (dims.z ?? 0) > 0 ? "1" : "0"; - try { - await prisma.$executeRaw` - UPDATE items_base - SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z ?? 0}, - allow_sit = ${cansiton ? "1" : "0"}, - allow_lay = ${canlayon ? "1" : "0"}, - allow_walk = ${canstandon ? "1" : "0"}, - allow_stack = ${allowStack}, - canlayon = ${canlayon ? "1" : "0"} - WHERE id = ${row.id} - `; - } catch (err) { - warnings.push( - `items_base dim update failed: ${(err as Error).message}`, - ); - } - - // Keep FurnitureData.json in sync with the regenerated metadata so the - // Nitro client sees up-to-date dims/flags without a full re-import. - try { - let habboIt = null; - try { - habboIt = await lookupHabboIt(classname); - } catch { - /* optional */ - } - const entry = buildFurniEntry({ - id: row.id, - classname, - revision: habboIt?.revision ?? 0, - category: habboIt?.category || "unknown", - name: row.publicName || habboIt?.name || classname, - description: habboIt?.description || "", - spriteId: row.spriteId, - dims: { x: dims.x, y: dims.y, z: dims.z ?? 0 }, - metadata: meta, - habboIt, - }); - await appendFurniEntry(entry, row.type); - } catch (err) { - warnings.push( - `FurnitureData.json sync failed: ${(err as Error).message}`, - ); - } - } catch (err) { - warnings.push(`SWF → Nitro conversion failed: ${(err as Error).message}`); - } - } - - // ── Repair icon (Nitro-style cascade) ─────────────────────────────── - // - // Nitro client icon URL template: `${hof.furni.url}/icons/%libname%%param%_icon.png` - // - %libname% = base classname (before `*`) - // - %param% = `_N` for color variants, empty otherwise - // - // So for `bonusrare18_1*0` habbo.com can host either: - // - bonusrare18_1_0_icon.png (color-specific) ← try first - // - bonusrare18_1_icon.png (base fallback, Nitro reuses for all colors) - // - // Cascade per revision: - // 1. color-specific URL (only if classname has `*`) - // 2. base URL (always) - // After exhausting the network: extract from SWF (embedded icon). - let iconRepaired = false; - let iconSource: "habbo-color" | "habbo-base" | "swf" | null = null; - if (needsIcon) { - const hasColor = starIdx !== -1; - const colorAwareName = classname.replace(/\*/g, "_"); - - for (const tryRev of revsToTry) { - // 1. color-specific icon (authoritative for color variants) - if (hasColor) { - const dl = await downloadFile( - `https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(colorAwareName)}_icon.png`, - iconPath, - { validate: "png" }, - ); - if (dl.ok) { - iconRepaired = true; - iconSource = "habbo-color"; - break; - } - } - // 2. base icon (also used by Nitro when no color-specific one exists) - const dlBase = await downloadFile( - `https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}_icon.png`, - iconPath, - { validate: "png" }, - ); - if (dlBase.ok) { - iconRepaired = true; - iconSource = "habbo-base"; - break; - } - } - - // 3. Fallback: extract from SWF (works offline + covers items with no public PNG) - if (!iconRepaired && existsSync(swfPath)) { - try { - const swfBuf = await fs.readFile(swfPath); - const iconPng = extractIconFromSwf(swfBuf, baseClassname); - if (iconPng) { - await fs.writeFile(iconPath, iconPng); - iconRepaired = true; - iconSource = "swf"; - warnings.push("Icon extracted from SWF (no PNG on habbo.com)"); - } - } catch (err) { - warnings.push(`SWF icon extraction failed: ${(err as Error).message}`); - } - } - - if (!iconRepaired) { - warnings.push( - existsSync(swfPath) - ? "Icon not on habbo.com and SWF has no embedded icon" - : `Icon not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`, - ); - } else if (iconSource === "habbo-base" && hasColor) { - warnings.push("Used base-classname icon (color-specific not published)"); - } - } - - const ok = (!needsNitro || nitroRepaired) && (!needsIcon || iconRepaired); - - // Flag items for which every external revision returned 404 → no point retrying later. - // Criteria: we tried to download something and nothing (not even the SWF extraction) - // produced a result. - const unrepairable = - !ok && - !iconRepaired && - !nitroRepaired && - // At least one outbound call was attempted and failed with 404-style errors - (warnings.some((w) => w.startsWith("SWF not found on habbo.com")) || - warnings.some((w) => w.startsWith("Icon not found on habbo.com"))); - - return { classname, ok, iconRepaired, nitroRepaired, warnings, unrepairable }; -} diff --git a/src/messages/en.json b/src/messages/en.json index 482bc15a..69a38c42 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2038,9 +2038,7 @@ "pets": "Pets", "petsDescription": "Import pet data and icons.", "clone": "Clone", - "cloneDescription": "Clone existing items from one source to another.", - "repair": "Repair", - "repairDescription": "Audit and repair imported assets." + "cloneDescription": "Clone existing items from one source to another." }, "translations": { "title": "Translations",