feat: relocate import tools into studio and harden catalog/furnidata integrity
CI / check (push) Failing after 26s
CI / release (push) Skipped
CI / deploy (push) Skipped

- Move /admin/import/* tools under /admin/studio/* and add studio nav, layout and tabs
- Add shared catalog maintenance panel plus a /admin/studio/maintenance tab
- Make furnidata reconciliation overwrite conflicting entries with the
  DB-authoritative items_base classname/id (surfaced via fixedConflicts)
- Add furnidata_translate_enabled setting to skip the heavy localized
  furnidata build during import (manual build-languages still forces it)
- Update staff smoke contract test for the studio hub
This commit is contained in:
openhands committed 2026-08-24 17:13:03 +02:00
1 parent c2e61dc324
commit c7ba04bda1
36 files changed
+616 -2835

No files matched your search

+62
View File
@@ -38,6 +38,68 @@ const nextConfig: NextConfig = {
deploymentId: resolveDeploymentId(),
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"],
// The /admin/import hub was consolidated into the catalogue studio at
// /admin/studio. Redirect the old routes so bookmarks/links don't 404.
async redirects() {
return [
{
source: "/admin/import",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/badges",
destination: "/admin/studio/badges",
permanent: true,
},
{
source: "/admin/import/furni",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/furni/upload",
destination: "/admin/studio/upload",
permanent: true,
},
{
source: "/admin/import/clothing",
destination: "/admin/studio/clothing",
permanent: true,
},
{
source: "/admin/import/effects",
destination: "/admin/studio/effects",
permanent: true,
},
{
source: "/admin/import/pets",
destination: "/admin/studio/pets",
permanent: true,
},
{
source: "/admin/import/clone",
destination: "/admin/studio/clone",
permanent: true,
},
{
source: "/admin/import/sync",
destination: "/admin/studio/sync",
permanent: true,
},
{
source: "/admin/import/repair-icons",
destination: "/admin/studio/repair-icons",
permanent: true,
},
{
source: "/admin/import/audit",
destination: "/admin/studio/audit",
permanent: true,
},
];
},
// Silence Turbopack warnings for the broad file patterns in src/lib.
turbopack: {
ignoreIssue: [
+2 -288
View File
@@ -1,291 +1,5 @@
"use client";
import { AlertTriangle, CheckCircle2, RefreshCw, Wrench } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
applyAlignIdsAction,
fixCatalogOffersAction,
fixEverythingAction,
fixSpriteIdsAction,
getFurniHealthAction,
previewAlignIdsAction,
reconcileIdsAction,
removeDuplicateItemsBaseAction,
} from "@/actions/furni-maintenance";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface Health {
itemsBaseTotal: number;
spriteIdMismatch: number;
catalogOfferMismatch: number;
duplicateClassnames: number;
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
}
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
export default function CatalogMaintenancePage() {
const [health, setHealth] = useState<Health | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [alignPreview, setAlignPreview] = useState<{
moves: number;
inserted: number;
skipped: string[];
} | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
const res = await getFurniHealthAction();
if (res.ok && res.data?.health) setHealth(res.data.health as Health);
else if (!res.ok) toast.error(res.error);
setAlignPreview(null);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function run(
key: string,
fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,
success: string,
) {
setBusy(key);
try {
const res = await fn();
if (res.ok) {
toast.success(success);
await refresh();
} else {
toast.error(res.error ?? "Failed");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(null);
}
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: number;
hint?: string;
}) {
const ok = value === 0;
return (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
<Badge variant={ok ? "default" : "destructive"}>
{ok ? <CheckCircle2 className="mr-1 h-3 w-3" /> : value}
</Badge>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Wrench className="h-5 w-5" /> Catalog maintenance
</h1>
<p className="text-sm text-muted-foreground">
Force the furniture tables to 100% consistency with
FurnitureData.json. Every action runs inside a database transaction
and rolls back on error.
</p>
</div>
<Button
variant="outline"
onClick={() => void refresh()}
disabled={loading}
>
<RefreshCw
className={loading ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"}
/>
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Current health</CardTitle>
<CardDescription>
{health
? `${health.itemsBaseTotal} items in items_base`
: "Loading…"}
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{health && (
<>
<Stat
label="sprite_id ≠ id"
value={health.spriteIdMismatch}
hint="items_base sprite drift"
/>
<Stat
label="offer_id ≠ id"
value={health.catalogOfferMismatch}
hint="catalog offer drift"
/>
<Stat
label="Duplicate classnames"
value={health.duplicateClassnames}
hint="items_base item_name repeats"
/>
<Stat
label="Duplicate catalog rows"
value={health.catalogDuplicates}
hint="same product listed twice on a page"
/>
<Stat
label="ID ≠ FurnitureData"
value={health.idMismatchVsFurnidata}
hint="items_base id differs from furnidata"
/>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
disabled={busy !== null}
onClick={() =>
void run("sprite", fixSpriteIdsAction, "sprite_id fixed")
}
>
Fix sprite_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run("offer", fixCatalogOffersAction, "catalog offer_id fixed")
}
>
Fix catalog offer_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"dup",
removeDuplicateItemsBaseAction,
"Duplicates removed (items + catalog)",
)
}
>
Remove duplicates (items + catalog)
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"reconcile",
reconcileIdsAction,
"FurnitureData reconciled",
)
}
>
Reconcile FurnitureData ↔ DB
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Align items_base IDs to FurnitureData</CardTitle>
<CardDescription>
Moves every items_base.id to the FurnitureData sprite id (source of
truth). Rewrites all {22} referencing tables. Preview first.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
disabled={busy !== null}
onClick={async () => {
setBusy("preview");
const res = await previewAlignIdsAction();
setBusy(null);
if (res.ok && res.data) {
setAlignPreview(res.data as typeof alignPreview);
toast.success("Preview ready");
} else if (!res.ok) toast.error(res.error ?? "Preview failed");
}}
>
Preview changes
</Button>
{alignPreview && (
<div className="rounded-md border p-3 text-sm">
<div>
Planned ID moves: <b>{alignPreview.moves}</b>
</div>
<div>
New items to insert: <b>{alignPreview.inserted}</b>
</div>
{alignPreview.skipped.length > 0 && (
<div className="mt-1 text-muted-foreground">
Skipped (id occupied): {alignPreview.skipped.join(", ")}
</div>
)}
</div>
)}
<Button
disabled={busy !== null || !alignPreview}
onClick={() =>
void run(
"align",
applyAlignIdsAction,
"items_base IDs aligned to FurnitureData",
)
}
>
Apply alignment
</Button>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" /> Fix everything
</CardTitle>
<CardDescription>
Runs sprite fix, offer fix, reconcile, dedup and ID alignment in
order. Heavy operation — make a backup first.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={busy !== null}
onClick={() =>
void run("all", fixEverythingAction, "All repairs applied")
}
>
Fix everything now
</Button>
</CardContent>
</Card>
</div>
);
return <CatalogMaintenancePanel />;
}
-21
View File
@@ -1,21 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportBadgesLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
<div key={`skeleton-field-${n}`} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`figure-skeleton-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
// src/app/[locale]/admin/import/effects/loading.tsx
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`effect-skeleton-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,45 +0,0 @@
import type { SyntheticEvent } from "react";
export function getFurniImageUrl(classname: string, revision: number) {
// Use official Habbo CDN for furniture icons
const base = classname.includes("*")
? classname.substring(0, classname.indexOf("*"))
: classname;
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
}
export function getFurniIconUrl(classname: string, revision: number) {
const iconName = classname.replace(/\*/g, "_");
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
}
export function handleImgError(
e: SyntheticEvent<HTMLImageElement>,
classname: string,
revision: number,
) {
const img = e.target as HTMLImageElement;
const iconName = classname.replace(/\*/g, "_");
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = getFurniIconUrl(classname, revision);
} else if (img.dataset.fallback === "1") {
img.dataset.fallback = "2";
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
} else {
// Show placeholder instead of invisible image
img.style.opacity = "0.3";
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
}
}
export const STATUS_LABELS: Record<string, string> = {
pending: "Pending",
started: "Starting",
downloading: "Downloading",
converting: "Converting",
writing_db: "Writing DB",
done: "Done",
failed: "Failed",
};
@@ -1,47 +0,0 @@
export interface FurniItem {
id: number;
classname: string;
type: string;
name: string;
description: string;
revision: number;
category: string;
alreadyImported: boolean;
nitroExists: boolean;
}
export interface Meta {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
export interface BatchItemStatus {
classname: string;
status:
| "pending"
| "started"
| "downloading"
| "converting"
| "writing_db"
| "done"
| "failed";
message?: string;
warnings?: string[];
itemId?: number;
}
export interface BatchSummary {
succeeded: number;
failed: number;
warnings: number;
duration: number;
}
export interface FurniStats {
totalInDb: number;
inCatalog: number;
missingNitro: number;
notInCatalog: number;
}
-21
View File
@@ -1,21 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportFurniLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
<div key={`skeleton-field-${n}`} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
}
-89
View File
@@ -1,89 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import type { Meta } from "./import-furni-types";
export function Pagination({
meta,
loading,
onPageChange,
}: {
meta: Meta | null;
loading: boolean;
onPageChange: (page: number) => void;
}) {
if (!meta || meta.lastPage <= 1) return null;
const nodes: ReactNode[] = [];
if (meta.currentPage > 1) {
nodes.push(
<Button
key="prev"
variant="ghost"
size="sm"
onClick={() => onPageChange(meta.currentPage - 1)}
disabled={loading}
className="text-xs"
>
&larr; Prev
</Button>,
);
}
for (let p = 1; p <= meta.lastPage; p++) {
if (p === meta.currentPage) {
nodes.push(
<Button
key={p}
variant="default"
size="sm"
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (
Math.abs(p - meta.currentPage) <= 2 ||
p === 1 ||
p === meta.lastPage
) {
nodes.push(
<Button
key={p}
variant="ghost"
size="sm"
onClick={() => onPageChange(p)}
disabled={loading}
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (Math.abs(p - meta.currentPage) === 3) {
nodes.push(
<span key={`dots-${p}`} className="text-muted-foreground text-xs px-1">
&hellip;
</span>,
);
}
}
if (meta.currentPage < meta.lastPage) {
nodes.push(
<Button
key="next"
variant="ghost"
size="sm"
onClick={() => onPageChange(meta.currentPage + 1)}
disabled={loading}
className="text-xs"
>
Next &rarr;
</Button>,
);
}
return (
<div className="flex items-center justify-center gap-1 mt-4 flex-wrap">
{nodes}
</div>
);
}
-113
View File
@@ -1,113 +0,0 @@
import {
BadgeCheck,
Cat,
ClipboardCheck,
Copy,
Dumbbell,
ImageIcon,
Puzzle,
RefreshCw,
Shirt,
Upload,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ImportPage() {
const t = await getTranslations("pages.admin.import");
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const SECTIONS = [
{
href: "/admin/import/badges",
label: t("badges"),
icon: BadgeCheck,
description: t("badgesDescription"),
},
{
href: "/admin/import/furni",
label: t("furni"),
icon: Puzzle,
description: t("furniDescription"),
},
{
href: "/admin/import/furni/upload",
label: t("upload"),
icon: Upload,
description: t("uploadDescription"),
},
{
href: "/admin/import/clothing",
label: t("clothing"),
icon: Shirt,
description: t("clothingDescription"),
},
{
href: "/admin/import/effects",
label: t("effects"),
icon: Dumbbell,
description: t("effectsDescription"),
},
{
href: "/admin/import/pets",
label: t("pets"),
icon: Cat,
description: t("petsDescription"),
},
{
href: "/admin/import/clone",
label: t("clone"),
icon: Copy,
description: t("cloneDescription"),
},
{
href: "/admin/import/sync",
label: t("sync"),
icon: RefreshCw,
description: t("syncDescription"),
},
{
href: "/admin/import/repair-icons",
label: t("repairIcons"),
icon: ImageIcon,
description: t("repairIconsDescription"),
},
{
href: "/admin/import/audit",
label: t("audit"),
icon: ClipboardCheck,
description: t("auditDescription"),
},
] as const;
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{SECTIONS.map(({ href, label, icon: Icon, description }) => (
<Link
key={href}
href={href}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:bg-accent"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold group-hover:text-primary">
{label}
</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`pet-skeleton-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportPetsClient } from "./import-pets-client";
export default async function ImportPetsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportPetsClient />;
}
@@ -164,6 +164,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
icon: "music",
description: "Public URLs and overrides for SWF / Nitro asset packages.",
fields: [
{
key: "furnidata_translate_enabled",
label: "Translate furniture names",
description:
"Rebuild FurnitureData_<lang>.json with translated names after each import. Disable to skip the LibreTranslate / deep-clone work and save CPU & RAM when you don't need localized furniture names. You can still rebuild on demand via the import 'build languages' action.",
type: "boolean",
defaultValue: "1",
},
{
key: "habbo_gamedata_hotel",
label: "Habbo hotel for furni names",
@@ -1,18 +1,19 @@
import { redirect } from "next/navigation";
import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AuditClient } from "./audit-client";
export default async function AuditPage() {
export default async function StudioAuditPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
Scan your entire furniture catalog for missing entries, broken
references, missing files, and items available from clone sources.
</p>
@@ -1,14 +1,17 @@
import { redirect } from "next/navigation";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportBadgesClient } from "./import-badges-client";
export default async function ImportBadgesPage(_props: {
export default async function StudioBadgesPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportBadgesClient />;
return (
<div className="p-4">
<ImportBadgesClient />
</div>
);
}
@@ -1,13 +1,17 @@
import { redirect } from "next/navigation";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportClothingClient } from "./import-clothing-client";
export default async function ImportClothingPage(_props: {
export default async function StudioClonePage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportClothingClient />;
return (
<div className="p-4">
<ImportCloneClient />
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="p-4">
<ImportClothingClient />
</div>
);
}
@@ -1,13 +1,17 @@
import { redirect } from "next/navigation";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportCloneClient } from "./import-clone-client";
export default async function ImportClonePage(_props: {
export default async function StudioEffectsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportCloneClient />;
return (
<div className="p-4">
<ImportEffectsClient />
</div>
);
}
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation";
import { StudioClient } from "@/components/admin/studio/studio-client";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getTreeFlat } from "@/lib/services/catalog-tree";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: {
export default async function StudioFurniPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
@@ -12,6 +13,15 @@ export default async function ImportFurniPage(_props: {
redirect("/admin");
}
const hotel = await getHabboGamedataHotel();
return <ImportFurniClient source={buildFurniImportSource(hotel)} />;
const [hotel, catalogTree] = await Promise.all([
getHabboGamedataHotel(),
getTreeFlat(),
]);
return (
<StudioClient
source={buildFurniImportSource(hotel)}
initialTree={catalogTree}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import {
BadgeCheck,
Cat,
ClipboardCheck,
Copy,
Dumbbell,
Image as ImageIcon,
type LucideIcon,
Puzzle,
RefreshCw,
Shirt,
Upload,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const TABS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin/studio/furni", label: "Furni", icon: Puzzle },
{ href: "/admin/studio/upload", label: "Upload", icon: Upload },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
{ href: "/admin/studio/clone", label: "Clone", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync", icon: RefreshCw },
{
href: "/admin/studio/repair-icons",
label: "Repair Icons",
icon: ImageIcon,
},
{ href: "/admin/studio/audit", label: "Audit", icon: ClipboardCheck },
{
href: "/admin/studio/maintenance",
label: "Catalogus onderhoud",
icon: Wrench,
},
];
export default function StudioLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
return (
<div
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-2 py-1.5">
{TABS.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
active
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
)}
>
<Icon size={13} />
{label}
</Link>
);
})}
</div>
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
</div>
);
}
@@ -0,0 +1,9 @@
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
export default function StudioCatalogMaintenancePage() {
return (
<div className="min-h-0">
<CatalogMaintenancePanel />
</div>
);
}
+2 -24
View File
@@ -1,27 +1,5 @@
import { redirect } from "next/navigation";
import { StudioClient } from "@/components/admin/studio/studio-client";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getTreeFlat } from "@/lib/services/catalog-tree";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
export default async function StudioPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const [hotel, catalogTree] = await Promise.all([
getHabboGamedataHotel(),
getTreeFlat(),
]);
return (
<StudioClient
source={buildFurniImportSource(hotel)}
initialTree={catalogTree}
/>
);
export default function StudioIndexPage() {
redirect("/admin/studio/furni");
}
@@ -1,14 +1,17 @@
// src/app/[locale]/admin/import/effects/page.tsx
import { redirect } from "next/navigation";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportEffectsClient } from "./import-effects-client";
export default async function ImportEffectsPage(_props: {
export default async function StudioPetsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportEffectsClient />;
return (
<div className="p-4">
<ImportPetsClient />
</div>
);
}
@@ -1,20 +1,21 @@
import { redirect } from "next/navigation";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairIconsClient } from "./repair-icons-client";
export default async function RepairIconsPage() {
export default async function StudioRepairIconsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
@@ -1,18 +1,19 @@
import { redirect } from "next/navigation";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { SyncAllClient } from "./sync-all-client";
export default async function SyncAllPage() {
export default async function StudioSyncPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
Scan all configured clone sources and automatically import any
furniture that is not yet present in your hotel. Missing .nitro files,
icons, database entries, catalog items, and FurnitureData.json will be
@@ -1,20 +1,21 @@
import { redirect } from "next/navigation";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { UploadFurniClient } from "./upload-furni-client";
export default async function UploadFurniPage() {
export default async function StudioUploadPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .nitro Furniture
</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
Upload a .nitro bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
+2 -1
View File
@@ -220,7 +220,7 @@ export const GET = withAdmin(
}
if (action === "build-languages") {
const languages = await buildLocalizedFurniDataFiles();
const languages = await buildLocalizedFurniDataFiles(true);
return apiOk({ languages });
}
@@ -680,6 +680,7 @@ export const POST = withAdmin(
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataFixedConflicts: furniReconcile.fixedConflicts,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
@@ -0,0 +1,296 @@
"use client";
import { AlertTriangle, CheckCircle2, RefreshCw, Wrench } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
applyAlignIdsAction,
fixCatalogOffersAction,
fixEverythingAction,
fixSpriteIdsAction,
getFurniHealthAction,
previewAlignIdsAction,
reconcileIdsAction,
removeDuplicateItemsBaseAction,
} from "@/actions/furni-maintenance";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface Health {
itemsBaseTotal: number;
spriteIdMismatch: number;
catalogOfferMismatch: number;
duplicateClassnames: number;
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
}
export function CatalogMaintenancePanel() {
const [health, setHealth] = useState<Health | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [alignPreview, setAlignPreview] = useState<{
moves: number;
inserted: number;
skipped: string[];
} | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
const res = await getFurniHealthAction();
if (res.ok && res.data?.health) setHealth(res.data.health as Health);
else if (!res.ok) toast.error(res.error);
setAlignPreview(null);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function run(
key: string,
fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,
success: string,
) {
setBusy(key);
try {
const res = await fn();
if (res.ok) {
toast.success(success);
await refresh();
} else {
toast.error(res.error ?? "Failed");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(null);
}
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: number;
hint?: string;
}) {
const ok = value === 0;
return (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
<Badge variant={ok ? "default" : "destructive"}>
{ok ? <CheckCircle2 className="mr-1 h-3 w-3" /> : value}
</Badge>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Wrench className="h-5 w-5" /> Catalog maintenance
</h1>
<p className="text-sm text-muted-foreground">
Force the furniture tables to 100% consistency with
FurnitureData.json. Every action runs inside a database transaction
and rolls back on error.
</p>
</div>
<Button
variant="outline"
onClick={() => void refresh()}
disabled={loading}
>
<RefreshCw
className={loading ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"}
/>
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Current health</CardTitle>
<CardDescription>
{health
? `${health.itemsBaseTotal} items in items_base`
: "Loading…"}
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{health && (
<>
<Stat
label="sprite_id ≠ id"
value={health.spriteIdMismatch}
hint="items_base sprite drift"
/>
<Stat
label="offer_id ≠ id"
value={health.catalogOfferMismatch}
hint="catalog offer drift"
/>
<Stat
label="Duplicate classnames"
value={health.duplicateClassnames}
hint="items_base item_name repeats"
/>
<Stat
label="Duplicate catalog rows"
value={health.catalogDuplicates}
hint="same product listed twice on a page"
/>
<Stat
label="ID ≠ FurnitureData"
value={health.idMismatchVsFurnidata}
hint="items_base id differs from furnidata"
/>
<Stat
label="Duplicate BC catalog rows"
value={health.catalogBcDuplicates}
hint="same product listed twice in builder club"
/>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
disabled={busy !== null}
onClick={() =>
void run("sprite", fixSpriteIdsAction, "sprite_id fixed")
}
>
Fix sprite_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run("offer", fixCatalogOffersAction, "catalog offer_id fixed")
}
>
Fix catalog offer_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"dup",
removeDuplicateItemsBaseAction,
"Duplicates removed (items + catalog)",
)
}
>
Remove duplicates (items + catalog)
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"reconcile",
reconcileIdsAction,
"FurnitureData reconciled",
)
}
>
Reconcile FurnitureData ↔ DB
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Align items_base IDs to FurnitureData</CardTitle>
<CardDescription>
Moves every items_base.id to the FurnitureData sprite id (source of
truth). Rewrites all referencing tables. Preview first.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
disabled={busy !== null}
onClick={async () => {
setBusy("preview");
const res = await previewAlignIdsAction();
setBusy(null);
if (res.ok && res.data) {
setAlignPreview(res.data as typeof alignPreview);
toast.success("Preview ready");
} else if (!res.ok) toast.error(res.error ?? "Preview failed");
}}
>
Preview changes
</Button>
{alignPreview && (
<div className="rounded-md border p-3 text-sm">
<div>
Planned ID moves: <b>{alignPreview.moves}</b>
</div>
<div>
New items to insert: <b>{alignPreview.inserted}</b>
</div>
{alignPreview.skipped.length > 0 && (
<div className="mt-1 text-muted-foreground">
Skipped (id occupied): {alignPreview.skipped.join(", ")}
</div>
)}
</div>
)}
<Button
disabled={busy !== null || !alignPreview}
onClick={() =>
void run(
"align",
applyAlignIdsAction,
"items_base IDs aligned to FurnitureData",
)
}
>
Apply alignment
</Button>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" /> Fix everything
</CardTitle>
<CardDescription>
Runs sprite fix, offer fix, reconcile, dedup and ID alignment in
order. Heavy operation — make a backup first.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={busy !== null}
onClick={() =>
void run("all", fixEverythingAction, "All repairs applied")
}
>
Fix everything now
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -602,10 +602,7 @@ export function StudioClient({
);
return (
<div
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<div className="flex h-full min-h-[520px] flex-col overflow-hidden">
{/* ── Header ─────────────────────────────────────────── */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-2.5">
-41
View File
@@ -13,7 +13,6 @@ import {
Gavel,
HelpCircle,
Image,
Import,
KeyRound,
Languages,
LayoutDashboard,
@@ -35,7 +34,6 @@ import {
Terminal,
Ticket,
Trophy,
Upload,
Users,
UsersRound,
Volume2,
@@ -219,38 +217,6 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
{ href: "/admin/devops/errors", labelKey: "errors" },
],
},
{
id: "import",
titleKey: "import",
subtitleKey: "importSubtitle",
icon: Upload,
prefixes: ["/admin/import"],
tabs: [
{
href: "/admin/import",
labelKey: "importOverview",
match: ["/admin/import"],
},
{ href: "/admin/import/badges", labelKey: "importBadges" },
{
href: "/admin/import/furni/upload",
labelKey: "importUpload",
match: ["/admin/import/furni/upload"],
},
{
href: "/admin/import/furni",
labelKey: "importFurni",
match: ["/admin/import/furni"],
},
{ href: "/admin/import/clothing", labelKey: "importClothing" },
{ href: "/admin/import/effects", labelKey: "importEffects" },
{ href: "/admin/import/pets", labelKey: "importPets" },
{ href: "/admin/import/clone", labelKey: "importClone" },
{ href: "/admin/import/sync", labelKey: "importSync" },
{ href: "/admin/import/repair-icons", labelKey: "importRepairIcons" },
{ href: "/admin/import/audit", labelKey: "importAudit" },
],
},
];
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
@@ -637,13 +603,6 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/studio"],
},
{
href: "/admin/import",
labelKey: "import",
icon: Import,
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/import"],
},
{
href: "/admin/translations",
labelKey: "translations",
-1
View File
@@ -6,7 +6,6 @@ const ROOTS = ["src/app/admin", "src/components/admin"];
const GRAPHICAL_ALLOWLIST = [
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.tsx",
"src/app/admin/import/furni/import-furni-client.tsx",
"src/components/admin/catalog/items-shop-preview.tsx",
"src/components/admin/media-grid.tsx",
];
+32 -3
View File
@@ -41,6 +41,22 @@ export const FURNIDATA_LANGUAGES = [
export type FurnidataLang = (typeof FURNIDATA_LANGUAGES)[number]["lang"];
/**
* Master switch for FurnitureData localization. Rebuilding the per-language
* files deep-clones the 100k+ entry master 13× and hits LibreTranslate, which
* can spike RAM/CPU on every import. Admins can disable it in settings to skip
* the work entirely (the master FurnitureData.json is still written by the
* import itself). The manual "build languages" action passes `force` to run
* regardless.
*/
async function translationEnabled(): Promise<boolean> {
try {
return await siteSettings.getBool("furnidata_translate_enabled", true);
} catch {
return true;
}
}
export interface LanguageFileResult {
lang: string;
hotel: string;
@@ -415,9 +431,15 @@ function cloneFurniData(
* translation exists for that classname. Runs under the furnidata lock so it
* never interleaves with concurrent imports.
*/
export async function buildLocalizedFurniDataFiles(): Promise<
LanguageFileResult[]
> {
export async function buildLocalizedFurniDataFiles(
force = false,
): Promise<LanguageFileResult[]> {
if (!force && !(await translationEnabled())) {
logger.debug(
"[furni-i18n] FurnitureData translation disabled by setting; skipping localized build.",
);
return [];
}
return withFurniDataLock(async () => {
const master = (await readFurniData()) as Record<
string,
@@ -554,8 +576,15 @@ export async function buildLocalizedFurniDataFiles(): Promise<
*/
export async function patchLocalizedFurniDataEntries(
entries: Array<{ classname: string; name?: string; description?: string }>,
force = false,
): Promise<LanguageFileResult[]> {
if (entries.length === 0) return [];
if (!force && !(await translationEnabled())) {
logger.debug(
"[furni-i18n] FurnitureData translation disabled by setting; skipping localized patch.",
);
return [];
}
return withFurniDataLock(async () => {
const maps = await Promise.all(
+32 -7
View File
@@ -485,7 +485,12 @@ export interface FurniDataReconcileResult {
normalizedIds: number;
/** items_base rows with no matching furnitype entry (need re-import). */
missing: number;
/** spriteIds used by a different classname in the file; left untouched. */
/** spriteIds used by a different classname in the file, now resolved by
* overwriting the entry's classname with the items_base (authoritative)
* value so the client renders the correct furniture. */
fixedConflicts: number;
/** spriteIds still used by a different classname after the pass (none, once
* fixedConflicts resolves every conflict). */
conflicts: number;
}
@@ -498,8 +503,10 @@ export interface FurniDataReconcileResult {
* the emulator's catalog offer lookup stays in sync with the local ids;
* - rows missing entirely from the file are counted (they need a re-import
* or batch-regen, since building an entry requires the SWF/nitro metadata);
* - genuine spriteId conflicts (id used by a different classname) are counted
* but never auto-fixed.
* - genuine spriteId conflicts (id used by a different classname) are resolved
* in favour of items_base: the furnidata entry occupying that id has its
* classname overwritten with the items_base classname, so the client renders
* the real (imported) furniture instead of a legacy/conflicting one.
*/
export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconcileResult> {
return withFurniDataLock(async () => {
@@ -539,7 +546,8 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
let fixedOfferIds = 0;
let normalizedIds = 0;
let missing = 0;
let conflicts = 0;
let fixedConflicts = 0;
const conflicts = 0;
const entryOfferId = (entry: Record<string, unknown>): number => {
const v = Number(entry.offerid);
@@ -551,7 +559,17 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
const existing = byId.get(spriteId);
if (existing) {
if (existing.entry.classname !== row.itemName) {
conflicts++;
// items_base is authoritative: this furnidata entry sits at the
// wrong classname for this id (legacy/conflicting data). Overwrite
// it so the client renders the imported furniture, not a stray one.
const displaced = String(existing.entry.classname ?? "");
existing.entry.classname = row.itemName;
existing.entry.id = spriteId;
existing.entry.offerid = spriteId;
// Keep the in-memory maps coherent for the corrected classname.
byClass.set(row.itemName, existing);
if (displaced) byClass.delete(displaced);
fixedConflicts++;
} else if (entryOfferId(existing.entry) !== spriteId) {
existing.entry.offerid = spriteId;
fixedOfferIds++;
@@ -589,10 +607,17 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
}
}
if (fixedIds > 0 || fixedOfferIds > 0) {
if (fixedIds > 0 || fixedOfferIds > 0 || fixedConflicts > 0) {
await writeFurniData(furniData as Record<string, unknown>);
}
return { fixedIds, fixedOfferIds, normalizedIds, missing, conflicts };
return {
fixedIds,
fixedOfferIds,
normalizedIds,
missing,
fixedConflicts,
conflicts,
};
});
}
+6 -4
View File
@@ -180,8 +180,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => {
const before = buildMaps(await readFurniData());
// Genuine id conflicts (our spriteId is owned by a different classname
// in the file) are left untouched by design — they are a separate
// pre-existing data problem, not part of this fix.
// in the file) are resolved in favour of items_base: the entry keeps its
// id but its classname is overwritten with the items_base classname.
let conflicts = 0;
let missing = 0;
for (const row of rows) {
@@ -205,7 +205,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => {
const result = await reconcileFurniDataWithItemsBase();
expect(result.fixedIds).toBe(idMisBefore);
expect(result.conflicts).toBe(conflicts);
expect(result.fixedConflicts).toBe(conflicts);
expect(result.conflicts).toBe(0);
expect(result.missing).toBe(missing);
// Mirror the function's contract exactly: rows whose id IS keyed in the
@@ -218,7 +219,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => {
// furnidata file can only hold one id per classname, so a duplicate
// classname can never satisfy this invariant for all its rows. Those
// are repaired by the dedicated duplicate-classname tool, not by this
// function. Genuine id conflicts are likewise out of scope.
// function. Genuine id conflicts are now resolved by overwriting the
// entry's classname with the items_base value (see fixedConflicts).
const dupClassnames = new Map<string, number>();
for (const row of rows)
dupClassnames.set(
+4 -4
View File
@@ -243,11 +243,11 @@ describe("staff smoke contract", () => {
);
});
it("ships import hub with synced badge import path", () => {
it("ships studio hub with synced badge import path", () => {
const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
expect(nav).toContain('id: "import"');
expect(nav).toContain("importSubtitle");
expect(nav).toContain("/admin/import");
expect(nav).toContain('href: "/admin/studio"');
expect(nav).toContain('labelKey: "studio"');
expect(nav).toContain('"/admin/studio"');
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
const badgeService = readFileSync(
"src/lib/services/import-badge.ts",