Remove import/repair feature and related API routes.
Local Build and Deploy / deploy (push) Successful in 1m41s
Local Build and Deploy / deploy (push) Successful in 1m41s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
bb7d581084
commit
49d204e85c
9 files changed
+2
-1468
No files matched your search
@@ -5,7 +5,6 @@ import {
|
|||||||
Dumbbell,
|
Dumbbell,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Shirt,
|
Shirt,
|
||||||
Wrench,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -56,12 +55,6 @@ export default async function ImportPage() {
|
|||||||
icon: Copy,
|
icon: Copy,
|
||||||
description: t("cloneDescription"),
|
description: t("cloneDescription"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/admin/import/repair",
|
|
||||||
label: t("repair"),
|
|
||||||
icon: Wrench,
|
|
||||||
description: t("repairDescription"),
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 <RepairClient />;
|
|
||||||
}
|
|
||||||
@@ -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<string, (m: MissingItem) => 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<AuditSummary | null>(null);
|
|
||||||
const [auditing, setAuditing] = useState(false);
|
|
||||||
const [repairing, setRepairing] = useState(false);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>("all");
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [progress, setProgress] = useState<Map<string, ItemProgress>>(
|
|
||||||
new Map(),
|
|
||||||
);
|
|
||||||
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Repair Imported Assets
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Re-download missing{" "}
|
|
||||||
<code className="rounded bg-muted px-1">.nitro</code> bundles and
|
|
||||||
icons for items already imported. Items with no public asset on
|
|
||||||
habbo.com can be deleted from here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={runAudit} disabled={auditing || repairing}>
|
|
||||||
{auditing ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Scanning…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />{" "}
|
|
||||||
{summary ? "Re-scan" : "Scan Assets"}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Initial empty state */}
|
|
||||||
{!summary && !auditing && (
|
|
||||||
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
|
||||||
<Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
|
|
||||||
<p className="font-medium">
|
|
||||||
Click “Scan Assets” to audit imported furniture.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This checks every row in <code>items_base</code> against files on
|
|
||||||
disk. Scanning ~33k items takes a few seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
{summary && (
|
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
|
||||||
<StatCard
|
|
||||||
label="Imported total"
|
|
||||||
value={summary.total}
|
|
||||||
tone="default"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Missing both"
|
|
||||||
value={summary.missingBoth}
|
|
||||||
tone="danger"
|
|
||||||
sub="no .nitro · no icon"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Missing .nitro"
|
|
||||||
value={summary.missingNitro}
|
|
||||||
tone="warn"
|
|
||||||
sub="shows black cube"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Missing icon"
|
|
||||||
value={summary.missingIcon}
|
|
||||||
tone="info"
|
|
||||||
sub="generic icon in client"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Unrepairable"
|
|
||||||
value={summary.unrepairable}
|
|
||||||
tone="danger"
|
|
||||||
sub="no asset on habbo.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Batch repair summary */}
|
|
||||||
{lastSummary && (
|
|
||||||
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
|
||||||
Last run: <b>{lastSummary.succeeded}</b> fixed
|
|
||||||
{lastSummary.partial > 0 && (
|
|
||||||
<>
|
|
||||||
, <b>{lastSummary.partial}</b> partial
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{lastSummary.failed > 0 && (
|
|
||||||
<>
|
|
||||||
, <b className="text-destructive">{lastSummary.failed}</b> failed
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{lastSummary.unrepairable > 0 && (
|
|
||||||
<>
|
|
||||||
, <b className="text-destructive">{lastSummary.unrepairable}</b>{" "}
|
|
||||||
unrepairable
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
· {(lastSummary.duration / 1000).toFixed(1)}s
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table area */}
|
|
||||||
{summary && summary.items.length === 0 && (
|
|
||||||
<div className="rounded-md border bg-muted/30 p-10 text-center">
|
|
||||||
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-[var(--admin-success)]" />
|
|
||||||
<p className="font-medium">
|
|
||||||
All imported items have their assets on disk.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{summary.total} items scanned, nothing to repair.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{summary && summary.items.length > 0 && (
|
|
||||||
<>
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="relative flex-1 min-w-[240px]">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Filter by classname, name, or id…"
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => {
|
|
||||||
// Skip unrepairable items — no point paying the network cost for them
|
|
||||||
const pool =
|
|
||||||
selectedCount > 0
|
|
||||||
? selectedArray
|
|
||||||
: filtered.map((i) => i.classname);
|
|
||||||
const unrep =
|
|
||||||
summary?.items
|
|
||||||
.filter((i) => i.unrepairable)
|
|
||||||
.map((i) => i.classname) ?? [];
|
|
||||||
const unrepSet = new Set(unrep);
|
|
||||||
const targets = pool.filter((cn) => !unrepSet.has(cn));
|
|
||||||
if (targets.length === 0) {
|
|
||||||
toast.info(
|
|
||||||
"Nothing to repair — selected items are all flagged unrepairable.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
runRepair(targets);
|
|
||||||
}}
|
|
||||||
disabled={repairing || filtered.length === 0}
|
|
||||||
>
|
|
||||||
{repairing ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Repairing…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="mr-2 h-4 w-4" />
|
|
||||||
{selectedCount > 0
|
|
||||||
? `Repair Selected (${selectedCount})`
|
|
||||||
: `Repair All Visible (${filtered.length})`}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setConfirmDeleteAll(true)}
|
|
||||||
disabled={repairing || selectedCount === 0}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete Selected ({selectedCount})
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{summary && summary.unrepairable > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
// Auto-select every unrepairable item, then open the same confirm dialog
|
|
||||||
const unrep = summary.items
|
|
||||||
.filter((i) => i.unrepairable)
|
|
||||||
.map((i) => i.classname);
|
|
||||||
setSelected(new Set(unrep));
|
|
||||||
setConfirmDeleteAll(true);
|
|
||||||
}}
|
|
||||||
disabled={repairing}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete All Unrepairable ({summary.unrepairable})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs
|
|
||||||
value={tab}
|
|
||||||
onValueChange={(v) => setTab(v as keyof typeof TAB_FILTERS)}
|
|
||||||
>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="all">
|
|
||||||
All ({summary.items.length})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="both">
|
|
||||||
Both ({summary.missingBoth})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="nitro">
|
|
||||||
.nitro ({summary.missingNitro})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="icon">
|
|
||||||
Icon ({summary.missingIcon})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="unrepairable">
|
|
||||||
Unrepairable ({summary.unrepairable})
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value={tab} className="mt-4">
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[40px]">
|
|
||||||
<Checkbox
|
|
||||||
checked={allSelected}
|
|
||||||
onCheckedChange={toggleAll}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-[80px]">ID</TableHead>
|
|
||||||
<TableHead>Classname</TableHead>
|
|
||||||
<TableHead className="w-[90px]">Rev</TableHead>
|
|
||||||
<TableHead className="w-[100px]">.nitro</TableHead>
|
|
||||||
<TableHead className="w-[100px]">Icon</TableHead>
|
|
||||||
<TableHead className="w-[100px]">SWF</TableHead>
|
|
||||||
<TableHead className="w-[240px]">Status</TableHead>
|
|
||||||
<TableHead className="w-[120px]">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={9}
|
|
||||||
className="py-10 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
No items match the current filter.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{filtered.map((it) => (
|
|
||||||
<ItemRow
|
|
||||||
key={it.classname}
|
|
||||||
item={it}
|
|
||||||
selected={selected.has(it.classname)}
|
|
||||||
onToggle={() => toggleOne(it.classname)}
|
|
||||||
progress={progress.get(it.classname)}
|
|
||||||
onRepair={() => runRepair([it.classname])}
|
|
||||||
disabled={repairing}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={confirmDeleteAll} onOpenChange={setConfirmDeleteAll}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
Delete {selectedCount} imported item
|
|
||||||
{selectedCount === 1 ? "" : "s"}?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This removes the rows from <code>items_base</code> /{" "}
|
|
||||||
<code>catalog_items</code>, the entry in{" "}
|
|
||||||
<code>FurnitureData.json</code>, 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.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setConfirmDeleteAll(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={deleteSelected}>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div className={`rounded-md border p-4 ${tones[tone]}`}>
|
|
||||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</div>
|
|
||||||
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileBadge({ present }: { present: boolean }) {
|
|
||||||
return present ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" /> On disk
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="border-destructive/40 bg-destructive/10 text-destructive"
|
|
||||||
>
|
|
||||||
<XCircle className="mr-1 h-3 w-3" /> Missing
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusCell({ progress }: { progress?: ItemProgress }) {
|
|
||||||
if (!progress || progress.status === "idle")
|
|
||||||
return <span className="text-muted-foreground text-xs">—</span>;
|
|
||||||
if (progress.status === "working") {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
{progress.message || "Working…"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (progress.status === "done") {
|
|
||||||
const parts = [
|
|
||||||
progress.nitroRepaired ? ".nitro ✓" : null,
|
|
||||||
progress.iconRepaired ? "icon ✓" : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
|
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
|
||||||
{parts.length > 0 ? parts.join(" · ") : "Repaired"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (progress.status === "partial") {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
|
|
||||||
title={progress.message}
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
Partial · {progress.message || "check logs"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="flex items-center gap-1 text-xs text-destructive"
|
|
||||||
title={progress.message}
|
|
||||||
>
|
|
||||||
<XCircle className="h-3 w-3" />
|
|
||||||
{progress.message || "Failed"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<TableRow className={isUnrepairable ? "bg-destructive/5" : undefined}>
|
|
||||||
<TableCell>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={onToggle}
|
|
||||||
aria-label={`Select ${item.classname}`}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-sm">{item.classname}</span>
|
|
||||||
{isUnrepairable && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="border-destructive/40 bg-destructive/10 text-destructive"
|
|
||||||
>
|
|
||||||
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{item.publicName}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">{item.revision || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<FileBadge present={item.hasNitro} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<FileBadge present={item.hasIcon} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<FileBadge present={item.hasSwf} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusCell progress={progress} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onRepair}
|
|
||||||
disabled={
|
|
||||||
disabled || progress?.status === "working" || isUnrepairable
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
isUnrepairable
|
|
||||||
? "habbo.com does not have this asset"
|
|
||||||
: "Retry download + convert"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{progress?.status === "working" ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Wrench className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown>);
|
|
||||||
});
|
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -21,8 +21,6 @@ const ROUTES = [
|
|||||||
"pets",
|
"pets",
|
||||||
"pets/batch",
|
"pets/batch",
|
||||||
"pets/icon",
|
"pets/icon",
|
||||||
"repair",
|
|
||||||
"repair/audit",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("admin import backend contract", () => {
|
describe("admin import backend contract", () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
|||||||
import { lookupHabboIt } 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.
|
// 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 {
|
export {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
validatePngBytes,
|
validatePngBytes,
|
||||||
|
|||||||
@@ -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<AuditSummary> {
|
|
||||||
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<MissingKind, number> = { 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<RepairResult> {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -2038,9 +2038,7 @@
|
|||||||
"pets": "Pets",
|
"pets": "Pets",
|
||||||
"petsDescription": "Import pet data and icons.",
|
"petsDescription": "Import pet data and icons.",
|
||||||
"clone": "Clone",
|
"clone": "Clone",
|
||||||
"cloneDescription": "Clone existing items from one source to another.",
|
"cloneDescription": "Clone existing items from one source to another."
|
||||||
"repair": "Repair",
|
|
||||||
"repairDescription": "Audit and repair imported assets."
|
|
||||||
},
|
},
|
||||||
"translations": {
|
"translations": {
|
||||||
"title": "Translations",
|
"title": "Translations",
|
||||||
|
|||||||
Reference in new issue
Block a user