fix(catalog): check source assets and offer reachable import alternatives
This commit is contained in:
1 parent
8c1efae296
commit
159b1f7d1b
10 files changed
+646
-56
No files matched your search
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
guard: vi.fn(),
|
||||||
|
source: vi.fn(),
|
||||||
|
official: vi.fn(),
|
||||||
|
inspect: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/api-handler", () => ({
|
||||||
|
withAdmin: (options: unknown, handler: unknown) => {
|
||||||
|
mocks.guard(options);
|
||||||
|
return handler;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
|
||||||
|
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
||||||
|
getOfficialHabboFurnidata: mocks.official,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/furniture-source-assets", () => ({
|
||||||
|
inspectSourceAssets: mocks.inspect,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
id: 1,
|
||||||
|
classname: "chair",
|
||||||
|
name: "Chair",
|
||||||
|
description: "",
|
||||||
|
revision: 1,
|
||||||
|
type: "flooritem",
|
||||||
|
category: "other",
|
||||||
|
};
|
||||||
|
const request = (body: unknown) =>
|
||||||
|
new NextRequest("http://localhost/api/admin/studio/source-assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.source.mockReset().mockResolvedValue(null);
|
||||||
|
mocks.official.mockReset().mockResolvedValue(new Map());
|
||||||
|
mocks.inspect
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({
|
||||||
|
classname: "chair",
|
||||||
|
state: "missing",
|
||||||
|
revision: 1,
|
||||||
|
alternatives: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("requires import permission", () =>
|
||||||
|
expect(mocks.guard).toHaveBeenCalledWith({
|
||||||
|
permission: PERMS.ASSETS_IMPORT,
|
||||||
|
}));
|
||||||
|
it.each([
|
||||||
|
[],
|
||||||
|
Array(21).fill(item),
|
||||||
|
[{ ...item, classname: "../secret" }],
|
||||||
|
[{ ...item, revision: -1 }],
|
||||||
|
[null],
|
||||||
|
])("rejects invalid items before network checks: %j", async (items) => {
|
||||||
|
expect((await POST(request({ items }))).status).toBe(400);
|
||||||
|
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects unknown configured sources", async () => {
|
||||||
|
expect(
|
||||||
|
(await POST(request({ items: [item], sourceId: "missing" }))).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("uses server source configuration instead of client URLs", async () => {
|
||||||
|
const source = { id: "custom", nitroBaseUrl: "https://configured.example" };
|
||||||
|
mocks.source.mockResolvedValue(source);
|
||||||
|
const response = await POST(
|
||||||
|
request({
|
||||||
|
items: [item],
|
||||||
|
sourceId: "custom",
|
||||||
|
nitroBaseUrl: "https://untrusted.example",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mocks.inspect).toHaveBeenCalledWith(item, [], source);
|
||||||
|
expect((await response.json()).items[0].state).toBe("missing");
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
|
import type {
|
||||||
|
SourceAssetCheck,
|
||||||
|
SourceAssetItem,
|
||||||
|
} from "@/lib/furni/source-assets";
|
||||||
|
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { getSource } from "@/lib/services/clone-sources";
|
||||||
|
import { inspectSourceAssets } from "@/lib/services/furniture-source-assets";
|
||||||
|
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||||
|
export const POST = withAdmin(
|
||||||
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
|
async (request) => {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (
|
||||||
|
!Array.isArray(body?.items) ||
|
||||||
|
body.items.length > 20 ||
|
||||||
|
!validateClassnames(
|
||||||
|
body.items.map((item: SourceAssetItem) => item?.classname),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return apiError("Provide 1–20 furniture items", 400);
|
||||||
|
if (
|
||||||
|
body.items.some(
|
||||||
|
(item: SourceAssetItem) =>
|
||||||
|
!Number.isSafeInteger(item.revision) ||
|
||||||
|
item.revision < 0 ||
|
||||||
|
typeof item.name !== "string" ||
|
||||||
|
item.name.length > 500,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return apiError("Invalid source metadata", 400);
|
||||||
|
const source = body.sourceId ? await getSource(body.sourceId) : null;
|
||||||
|
if (body.sourceId && !source) return apiError("Source not found", 404);
|
||||||
|
const entries = await getOfficialHabboFurnidata();
|
||||||
|
const official: SourceAssetItem[] = [...entries.values()].map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
classname: entry.classname,
|
||||||
|
name: entry.name,
|
||||||
|
description: entry.description,
|
||||||
|
type: entry.itemType === "i" ? "wallitem" : "flooritem",
|
||||||
|
revision: entry.revision,
|
||||||
|
category: entry.category,
|
||||||
|
}));
|
||||||
|
const results: SourceAssetCheck[] = [];
|
||||||
|
for (let offset = 0; offset < body.items.length; offset += 4)
|
||||||
|
results.push(
|
||||||
|
...(await Promise.all(
|
||||||
|
body.items
|
||||||
|
.slice(offset, offset + 4)
|
||||||
|
.map((item: SourceAssetItem) =>
|
||||||
|
inspectSourceAssets(item, official, source),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
return apiOk({ items: results });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -16,23 +16,33 @@ import {
|
|||||||
useFurnitureInspection,
|
useFurnitureInspection,
|
||||||
} from "./furniture-inspector";
|
} from "./furniture-inspector";
|
||||||
import type { FurniItem } from "./studio-types";
|
import type { FurniItem } from "./studio-types";
|
||||||
|
import { useSourceAssetChecks } from "./use-source-asset-checks";
|
||||||
|
|
||||||
export function ImportReview({
|
export function ImportReview({
|
||||||
items,
|
items: originalItems,
|
||||||
|
sourceId,
|
||||||
source,
|
source,
|
||||||
translation,
|
translation,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
items: FurniItem[];
|
items: FurniItem[];
|
||||||
|
sourceId?: string;
|
||||||
source: string;
|
source: string;
|
||||||
translation: string;
|
translation: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: (items: FurniItem[]) => void;
|
onConfirm: (items: FurniItem[], replaced?: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [items, setItems] = useState(originalItems);
|
||||||
|
const [replacements, setReplacements] = useState<string[]>([]);
|
||||||
const inspection = useFurnitureInspection(
|
const inspection = useFurnitureInspection(
|
||||||
items.map((item) => item.classname),
|
items.map((item) => item.classname),
|
||||||
);
|
);
|
||||||
|
const assets = useSourceAssetChecks(items, sourceId, inspection);
|
||||||
|
const assetsByName = new Map(
|
||||||
|
assets.items.map((item) => [item.classname, item]),
|
||||||
|
);
|
||||||
|
const unavailable = assets.items.filter((item) => item.state === "missing");
|
||||||
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
|
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
|
||||||
const [step, setStep] = useState<"review" | "confirm">("review");
|
const [step, setStep] = useState<"review" | "confirm">("review");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -42,7 +52,11 @@ export function ImportReview({
|
|||||||
const complete = items.every((item) => localByName.has(item.classname));
|
const complete = items.every((item) => localByName.has(item.classname));
|
||||||
const ready = items.filter((item) => {
|
const ready = items.filter((item) => {
|
||||||
const local = localByName.get(item.classname);
|
const local = localByName.get(item.classname);
|
||||||
return local && compareFurniture(item, local).state !== "conflict";
|
return (
|
||||||
|
local &&
|
||||||
|
assetsByName.get(item.classname)?.state !== "missing" &&
|
||||||
|
compareFurniture(item, local).state !== "conflict"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const blocked = items.filter((item) => {
|
const blocked = items.filter((item) => {
|
||||||
const local = localByName.get(item.classname);
|
const local = localByName.get(item.classname);
|
||||||
@@ -98,6 +112,104 @@ export function ImportReview({
|
|||||||
<strong>Selection:</strong> {items.length} furniture items
|
<strong>Selection:</strong> {items.length} furniture items
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{replacements.length > 0 && (
|
||||||
|
<p className="text-sm">
|
||||||
|
Replacements selected: {replacements.join(" · ")}{" "}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setItems(originalItems);
|
||||||
|
setReplacements([]);
|
||||||
|
setStep("review");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Undo replacements
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{assets.loading && !inspection.loading && (
|
||||||
|
<p role="status">
|
||||||
|
Checking source files… {assets.items.length} checked
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{assets.error && (
|
||||||
|
<div role="alert">
|
||||||
|
<p>{assets.error}</p>
|
||||||
|
<Button variant="outline" onClick={assets.retry}>
|
||||||
|
Retry source check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{assets.items.some((item) => item.state === "unknown") && (
|
||||||
|
<p className="text-sm text-[var(--admin-warning)]">
|
||||||
|
Some sources could not be verified. The import will still try to
|
||||||
|
download their files.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{unavailable.map((check) => (
|
||||||
|
<section
|
||||||
|
key={check.classname}
|
||||||
|
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Source files unavailable: {check.classname}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
This item will be skipped. Choose a reachable alternative below
|
||||||
|
to replace it in this import.
|
||||||
|
</p>
|
||||||
|
{check.alternatives.length === 0 && (
|
||||||
|
<p className="text-sm">
|
||||||
|
No verified alternative found. Upload the original .nitro from
|
||||||
|
the import page if you have it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{check.alternatives.map((alternative) => (
|
||||||
|
<div
|
||||||
|
key={alternative.classname}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-[var(--admin-canvas)] p-2"
|
||||||
|
>
|
||||||
|
<div className="text-sm">
|
||||||
|
<strong>{alternative.name}</strong>
|
||||||
|
<p className="font-mono text-xs">{alternative.classname}</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{alternative.type} · Revision {alternative.revision} · ID{" "}
|
||||||
|
{alternative.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setItems((current) => [
|
||||||
|
...new Map(
|
||||||
|
current.map((item) => {
|
||||||
|
const next =
|
||||||
|
item.classname === check.classname
|
||||||
|
? {
|
||||||
|
...alternative,
|
||||||
|
alreadyImported: false,
|
||||||
|
nitroExists: false,
|
||||||
|
}
|
||||||
|
: item;
|
||||||
|
return [next.classname, next] as const;
|
||||||
|
}),
|
||||||
|
).values(),
|
||||||
|
]);
|
||||||
|
setReplacements((current) => [
|
||||||
|
...current,
|
||||||
|
`${check.classname} → ${alternative.classname}`,
|
||||||
|
]);
|
||||||
|
setStep("review");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use this alternative
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
{inspection.loading ? (
|
{inspection.loading ? (
|
||||||
<p role="status">Checking local data and assets…</p>
|
<p role="status">Checking local data and assets…</p>
|
||||||
) : inspection.error ? (
|
) : inspection.error ? (
|
||||||
@@ -115,7 +227,8 @@ export function ImportReview({
|
|||||||
<>
|
<>
|
||||||
<p role="status" className="text-sm">
|
<p role="status" className="text-sm">
|
||||||
{ready.length} ready to import · {existing} already in SQL
|
{ready.length} ready to import · {existing} already in SQL
|
||||||
(complete missing data) · {blocked} conflicts (skipped)
|
(complete missing data) · {blocked} conflicts ·{" "}
|
||||||
|
{unavailable.length} unavailable (skipped)
|
||||||
</p>
|
</p>
|
||||||
{inspection.items.some(
|
{inspection.items.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -257,6 +370,8 @@ export function ImportReview({
|
|||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
inspection.loading ||
|
inspection.loading ||
|
||||||
|
assets.loading ||
|
||||||
|
!!assets.error ||
|
||||||
!!inspection.error ||
|
!!inspection.error ||
|
||||||
!complete ||
|
!complete ||
|
||||||
ready.length === 0
|
ready.length === 0
|
||||||
@@ -265,7 +380,7 @@ export function ImportReview({
|
|||||||
if (step === "review") {
|
if (step === "review") {
|
||||||
setStep("confirm");
|
setStep("confirm");
|
||||||
setPage(0);
|
setPage(0);
|
||||||
} else onConfirm(ready);
|
} else onConfirm(ready, replacements.length > 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{step === "review"
|
{step === "review"
|
||||||
|
|||||||
@@ -2391,6 +2391,7 @@ export function StudioClient({
|
|||||||
{review && (
|
{review && (
|
||||||
<ImportReview
|
<ImportReview
|
||||||
items={review.items}
|
items={review.items}
|
||||||
|
sourceId={activeSource || undefined}
|
||||||
source={
|
source={
|
||||||
sources.find((entry) => entry.id === activeSource)?.name ??
|
sources.find((entry) => entry.id === activeSource)?.name ??
|
||||||
source.label
|
source.label
|
||||||
@@ -2403,10 +2404,10 @@ export function StudioClient({
|
|||||||
: "Off"
|
: "Off"
|
||||||
}
|
}
|
||||||
onCancel={() => setReview(null)}
|
onCancel={() => setReview(null)}
|
||||||
onConfirm={(accepted) => {
|
onConfirm={(accepted, replaced) => {
|
||||||
const single = review.single;
|
const single = review.single;
|
||||||
setReview(null);
|
setReview(null);
|
||||||
if (review.cloneAll)
|
if (review.cloneAll && !replaced)
|
||||||
void cloneAllMissing(accepted.map((item) => item.classname));
|
void cloneAllMissing(accepted.map((item) => item.classname));
|
||||||
else if (single && accepted[0]) void importSingle(accepted[0]);
|
else if (single && accepted[0]) void importSingle(accepted[0]);
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
|
||||||
|
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||||
|
import type { FurniItem } from "./studio-types";
|
||||||
|
export function useSourceAssetChecks(
|
||||||
|
items: FurniItem[],
|
||||||
|
sourceId: string | undefined,
|
||||||
|
local: { items: FurnitureInspection[]; loading: boolean; error: string },
|
||||||
|
) {
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const needed = items.filter(
|
||||||
|
(item) =>
|
||||||
|
!local.items.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.classname === item.classname && entry.nitro.exists === true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const key =
|
||||||
|
local.loading || local.error
|
||||||
|
? ""
|
||||||
|
: JSON.stringify({ items: needed, sourceId, attempt });
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
key: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
items: SourceAssetCheck[];
|
||||||
|
}>({ key: "", loading: true, error: "", items: [] });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!key) return;
|
||||||
|
const abort = new AbortController();
|
||||||
|
setState({ key, loading: true, error: "", items: [] });
|
||||||
|
void (async () => {
|
||||||
|
const { items, sourceId } = JSON.parse(key) as {
|
||||||
|
items: FurniItem[];
|
||||||
|
sourceId?: string;
|
||||||
|
};
|
||||||
|
const results: SourceAssetCheck[] = [];
|
||||||
|
try {
|
||||||
|
for (let offset = 0; offset < items.length; offset += 20) {
|
||||||
|
const res = await adminFetch("/api/admin/studio/source-assets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: items.slice(offset, offset + 20),
|
||||||
|
sourceId,
|
||||||
|
}),
|
||||||
|
signal: abort.signal,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !Array.isArray(data.items))
|
||||||
|
throw Error(data.error || "Could not check source assets");
|
||||||
|
results.push(...data.items);
|
||||||
|
if (!abort.signal.aborted)
|
||||||
|
setState({ key, items: [...results], loading: true, error: "" });
|
||||||
|
}
|
||||||
|
if (results.length !== items.length)
|
||||||
|
throw Error("Source check is incomplete. Retry before importing.");
|
||||||
|
if (!abort.signal.aborted)
|
||||||
|
setState({ key, items: results, loading: false, error: "" });
|
||||||
|
} catch (error) {
|
||||||
|
if (!abort.signal.aborted)
|
||||||
|
setState({
|
||||||
|
key,
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [key]);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: !key || state.key !== key || state.loading,
|
||||||
|
items: state.key === key ? state.items : [],
|
||||||
|
retry: () => setAttempt((value) => value + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface SourceAssetItem {
|
||||||
|
id: number;
|
||||||
|
classname: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
revision: number;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
export type AssetAvailability = "available" | "missing" | "unknown";
|
||||||
|
export interface SourceAssetCheck {
|
||||||
|
classname: string;
|
||||||
|
state: AssetAvailability;
|
||||||
|
revision: number;
|
||||||
|
alternatives: SourceAssetItem[];
|
||||||
|
}
|
||||||
|
export function relatedFurniture(
|
||||||
|
item: SourceAssetItem,
|
||||||
|
candidates: SourceAssetItem[],
|
||||||
|
) {
|
||||||
|
const tail = (name: string) =>
|
||||||
|
name.toLowerCase().split("_").slice(-2).join("_");
|
||||||
|
const label = (name: string) => (name ?? "").trim().toLowerCase();
|
||||||
|
return candidates
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.classname !== item.classname &&
|
||||||
|
candidate.type === item.type &&
|
||||||
|
((label(item.name) && label(candidate.name) === label(item.name)) ||
|
||||||
|
(item.classname.split("_").length >= 3 &&
|
||||||
|
tail(candidate.classname) === tail(item.classname))),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(label(b.name) === label(item.name)) -
|
||||||
|
Number(label(a.name) === label(item.name)) ||
|
||||||
|
a.classname.localeCompare(b.classname),
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
@@ -178,3 +178,22 @@ it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata
|
|||||||
).toContain("2000037258");
|
).toContain("2000037258");
|
||||||
expect(result.warnings.join(" ")).toContain("16940");
|
expect(result.warnings.join(" ")).toContain("16940");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not create or delete SQL rows when a new furniture asset is unavailable", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.official.mockResolvedValue(null);
|
||||||
|
mocks.download.mockResolvedValue(false);
|
||||||
|
mocks.select.mockImplementation(() => ({
|
||||||
|
from: () => ({ where: () => ({ limit: async () => [] }) }),
|
||||||
|
}));
|
||||||
|
mocks.stat.mockRejectedValue(new Error("missing"));
|
||||||
|
mocks.conflict.mockResolvedValue(null);
|
||||||
|
const result = await importSingleFurni({
|
||||||
|
...input,
|
||||||
|
classname: "missing_new_asset",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("Import not started");
|
||||||
|
expect(mocks.execute).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -619,45 +619,6 @@ export async function importSingleFurni(params: {
|
|||||||
const bestRevision = revision || officialHabbo?.revision || 0;
|
const bestRevision = revision || officialHabbo?.revision || 0;
|
||||||
const bestCategory = category || officialHabbo?.category || "unknown";
|
const bestCategory = category || officialHabbo?.category || "unknown";
|
||||||
|
|
||||||
// ── Insert into DB with enriched data ──────────────────────────────
|
|
||||||
onProgress?.("writing_db");
|
|
||||||
try {
|
|
||||||
if (preservingExisting) {
|
|
||||||
warnings.push(
|
|
||||||
`Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
|
|
||||||
);
|
|
||||||
} else if (isUpdate) {
|
|
||||||
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
|
|
||||||
// those metadata fields are persisted in FurnitureData.json (see entry below),
|
|
||||||
// not in the DB. Only refresh the columns this import manages.
|
|
||||||
await db.execute(sql`
|
|
||||||
UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
|
|
||||||
WHERE id = ${spriteId}
|
|
||||||
`);
|
|
||||||
} else {
|
|
||||||
itemId = await reserveFurnitureId(
|
|
||||||
originalId,
|
|
||||||
classname,
|
|
||||||
async (allocatedId) => {
|
|
||||||
await db.execute(
|
|
||||||
sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
spriteId = itemId;
|
|
||||||
if (spriteId !== originalId)
|
|
||||||
warnings.push(
|
|
||||||
`Source ID ${originalId} is occupied; imported with local ID ${spriteId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
warnings,
|
|
||||||
error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Derive file-safe names ────────────────────────────────────────
|
// ── Derive file-safe names ────────────────────────────────────────
|
||||||
const rev = bestRevision;
|
const rev = bestRevision;
|
||||||
const assetTargets = await getFurniAssetWriteTargets();
|
const assetTargets = await getFurniAssetWriteTargets();
|
||||||
@@ -828,7 +789,7 @@ export async function importSingleFurni(params: {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
const nitroValid = nitroStat && nitroStat.size >= 128;
|
const nitroValid = nitroStat && nitroStat.size >= 128;
|
||||||
|
|
||||||
if (!nitroValid && !conversionResult) {
|
if (!nitroValid) {
|
||||||
if (nitroStat) {
|
if (nitroStat) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
|
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
|
||||||
@@ -839,24 +800,56 @@ export async function importSingleFurni(params: {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
if (!isUpdate)
|
|
||||||
await db.execute(
|
|
||||||
sql`DELETE FROM items_base WHERE id = ${itemId} AND item_name = ${classname}`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* best effort cleanup */
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
warnings,
|
warnings,
|
||||||
error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import rolled back: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
|
error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import not started: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
|
||||||
.filter((warning) => /conversion|download|SWF/.test(warning))
|
.filter((warning) => /conversion|download|SWF/.test(warning))
|
||||||
.slice(-3)
|
.slice(-3)
|
||||||
.join(". ")}`,
|
.join(". ")}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Insert into DB with enriched data ──────────────────────────────
|
||||||
|
onProgress?.("writing_db");
|
||||||
|
try {
|
||||||
|
if (preservingExisting) {
|
||||||
|
warnings.push(
|
||||||
|
`Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
|
||||||
|
);
|
||||||
|
} else if (isUpdate) {
|
||||||
|
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
|
||||||
|
// those metadata fields are persisted in FurnitureData.json (see entry below),
|
||||||
|
// not in the DB. Only refresh the columns this import manages.
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
|
||||||
|
WHERE id = ${spriteId}
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
itemId = await reserveFurnitureId(
|
||||||
|
originalId,
|
||||||
|
classname,
|
||||||
|
async (allocatedId) => {
|
||||||
|
await db.execute(
|
||||||
|
sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
spriteId = itemId;
|
||||||
|
if (spriteId !== originalId)
|
||||||
|
warnings.push(
|
||||||
|
`Source ID ${originalId} is occupied; imported with local ID ${spriteId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
warnings,
|
||||||
|
error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings),
|
mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings),
|
||||||
mirrorFileToDirs(
|
mirrorFileToDirs(
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
relatedFurniture,
|
||||||
|
type SourceAssetItem,
|
||||||
|
} from "@/lib/furni/source-assets";
|
||||||
|
import { inspectSourceAssets, probeAsset } from "./furniture-source-assets";
|
||||||
|
|
||||||
|
const source: SourceAssetItem = {
|
||||||
|
id: 1,
|
||||||
|
classname: "h25_monkeybzn_pole",
|
||||||
|
name: "Jungle flags",
|
||||||
|
description: "Flags",
|
||||||
|
type: "flooritem",
|
||||||
|
revision: 73253,
|
||||||
|
category: "other",
|
||||||
|
};
|
||||||
|
const alternative = {
|
||||||
|
...source,
|
||||||
|
id: 2,
|
||||||
|
classname: "br_c25_monkeybzn_pole",
|
||||||
|
revision: 73257,
|
||||||
|
};
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
describe("source asset preflight", () => {
|
||||||
|
it("suggests reachable alternatives for unavailable source files", async () => {
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async (url: string) =>
|
||||||
|
new Response(null, { status: url.includes("/73257/") ? 200 : 404 }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const result = await inspectSourceAssets(source, [source, alternative]);
|
||||||
|
expect(result.state).toBe("missing");
|
||||||
|
expect(result.alternatives).toEqual([alternative]);
|
||||||
|
});
|
||||||
|
it("does not describe blocked or unreachable servers as missing files", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(null, { status: 403 })),
|
||||||
|
);
|
||||||
|
const result = await inspectSourceAssets(
|
||||||
|
{ ...source, classname: "blocked_test" },
|
||||||
|
[alternative],
|
||||||
|
);
|
||||||
|
expect(result.state).toBe("unknown");
|
||||||
|
expect(result.alternatives).toEqual([]);
|
||||||
|
});
|
||||||
|
it("accepts an available configured Nitro source", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async (url: string) =>
|
||||||
|
new Response(null, { status: url.endsWith(".nitro") ? 200 : 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await inspectSourceAssets(
|
||||||
|
{ ...source, classname: "custom_test" },
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
id: "custom",
|
||||||
|
name: "Custom",
|
||||||
|
hotel: "",
|
||||||
|
furnidataUrl: "https://source.example/data.json",
|
||||||
|
nitroBaseUrl: "https://source.example/furniture",
|
||||||
|
iconBaseUrl: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.state).toBe("available");
|
||||||
|
});
|
||||||
|
it("bounds suggestions and excludes different furniture types", () => {
|
||||||
|
expect(
|
||||||
|
relatedFurniture(source, [source, { ...alternative, type: "wallitem" }]),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
relatedFurniture(
|
||||||
|
source,
|
||||||
|
Array.from({ length: 20 }, (_, id) => ({
|
||||||
|
...alternative,
|
||||||
|
id,
|
||||||
|
classname: `f${id}_monkeybzn_pole`,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
).toHaveLength(5);
|
||||||
|
});
|
||||||
|
it("handles unnamed source entries while finding related assets", () => {
|
||||||
|
expect(
|
||||||
|
relatedFurniture(source, [
|
||||||
|
{ ...alternative, name: null as unknown as string },
|
||||||
|
]),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("caches a probe to avoid repeating CDN requests", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await probeAsset("https://example.test/cache.nitro");
|
||||||
|
await probeAsset("https://example.test/cache.nitro");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
AssetAvailability,
|
||||||
|
SourceAssetCheck,
|
||||||
|
SourceAssetItem,
|
||||||
|
} from "@/lib/furni/source-assets";
|
||||||
|
import { relatedFurniture } from "@/lib/furni/source-assets";
|
||||||
|
import type { CloneSource } from "./clone-sources";
|
||||||
|
import { browserHeaders } from "./import/core/browser-headers";
|
||||||
|
|
||||||
|
const cache = new Map<string, { expires: number; state: AssetAvailability }>();
|
||||||
|
export async function probeAsset(url: string): Promise<AssetAvailability> {
|
||||||
|
const cached = cache.get(url);
|
||||||
|
if (cached && cached.expires > Date.now()) return cached.state;
|
||||||
|
let state: AssetAvailability = "unknown";
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
|
signal: AbortSignal.timeout(6000),
|
||||||
|
headers: browserHeaders(),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
response.ok &&
|
||||||
|
!response.headers.get("content-type")?.includes("text/html")
|
||||||
|
)
|
||||||
|
state = "available";
|
||||||
|
else if (response.status === 404 || response.status === 410)
|
||||||
|
state = "missing";
|
||||||
|
} catch {
|
||||||
|
/* A network problem is not proof the asset is absent. */
|
||||||
|
}
|
||||||
|
if (cache.size >= 2000) cache.clear();
|
||||||
|
cache.set(url, { expires: Date.now() + 60000, state });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
export function furnitureAssetUrls(
|
||||||
|
item: SourceAssetItem,
|
||||||
|
source?: CloneSource | null,
|
||||||
|
) {
|
||||||
|
const base = encodeURIComponent(item.classname.split("*")[0]);
|
||||||
|
const urls: string[] = [];
|
||||||
|
if (source?.nitroBaseUrl)
|
||||||
|
urls.push(`${source.nitroBaseUrl.replace(/\/+$/, "")}/${base}.nitro`);
|
||||||
|
if (source?.sourceSwfBaseUrl) {
|
||||||
|
const root = source.sourceSwfBaseUrl.replace(/\/+$/, "");
|
||||||
|
urls.push(
|
||||||
|
`${root}/hof_furni/${item.revision}/${base}.swf`,
|
||||||
|
`${root}/hof_furni/${base}.swf`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
urls.push(
|
||||||
|
`https://images.habbo.com/dcr/hof_furni/${item.revision}/${base}.swf`,
|
||||||
|
);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
async function available(
|
||||||
|
item: SourceAssetItem,
|
||||||
|
source?: CloneSource | null,
|
||||||
|
): Promise<AssetAvailability> {
|
||||||
|
const states = await Promise.all(
|
||||||
|
furnitureAssetUrls(item, source).map(probeAsset),
|
||||||
|
);
|
||||||
|
return states.includes("available")
|
||||||
|
? "available"
|
||||||
|
: states.every((state) => state === "missing")
|
||||||
|
? "missing"
|
||||||
|
: "unknown";
|
||||||
|
}
|
||||||
|
export async function inspectSourceAssets(
|
||||||
|
item: SourceAssetItem,
|
||||||
|
official: SourceAssetItem[],
|
||||||
|
source?: CloneSource | null,
|
||||||
|
): Promise<SourceAssetCheck> {
|
||||||
|
const canonical = official.find(
|
||||||
|
(entry) => entry.classname === item.classname,
|
||||||
|
);
|
||||||
|
const effective = {
|
||||||
|
...item,
|
||||||
|
revision: item.revision || canonical?.revision || 0,
|
||||||
|
};
|
||||||
|
const state = await available(effective, source);
|
||||||
|
const alternatives: SourceAssetItem[] = [];
|
||||||
|
if (state === "missing") {
|
||||||
|
for (const candidate of relatedFurniture(
|
||||||
|
{ ...effective, name: canonical?.name || item.name },
|
||||||
|
official,
|
||||||
|
)) {
|
||||||
|
if ((await available(candidate)) === "available")
|
||||||
|
alternatives.push(candidate);
|
||||||
|
if (alternatives.length === 3) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
classname: item.classname,
|
||||||
|
revision: effective.revision,
|
||||||
|
state,
|
||||||
|
alternatives,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user