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,
|
||||
} from "./furniture-inspector";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
import { useSourceAssetChecks } from "./use-source-asset-checks";
|
||||
|
||||
export function ImportReview({
|
||||
items,
|
||||
items: originalItems,
|
||||
sourceId,
|
||||
source,
|
||||
translation,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
items: FurniItem[];
|
||||
sourceId?: string;
|
||||
source: string;
|
||||
translation: string;
|
||||
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(
|
||||
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 [step, setStep] = useState<"review" | "confirm">("review");
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -42,7 +52,11 @@ export function ImportReview({
|
||||
const complete = items.every((item) => localByName.has(item.classname));
|
||||
const ready = items.filter((item) => {
|
||||
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 local = localByName.get(item.classname);
|
||||
@@ -98,6 +112,104 @@ export function ImportReview({
|
||||
<strong>Selection:</strong> {items.length} furniture items
|
||||
</p>
|
||||
</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 ? (
|
||||
<p role="status">Checking local data and assets…</p>
|
||||
) : inspection.error ? (
|
||||
@@ -115,7 +227,8 @@ export function ImportReview({
|
||||
<>
|
||||
<p role="status" className="text-sm">
|
||||
{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>
|
||||
{inspection.items.some(
|
||||
(item) =>
|
||||
@@ -257,6 +370,8 @@ export function ImportReview({
|
||||
}}
|
||||
disabled={
|
||||
inspection.loading ||
|
||||
assets.loading ||
|
||||
!!assets.error ||
|
||||
!!inspection.error ||
|
||||
!complete ||
|
||||
ready.length === 0
|
||||
@@ -265,7 +380,7 @@ export function ImportReview({
|
||||
if (step === "review") {
|
||||
setStep("confirm");
|
||||
setPage(0);
|
||||
} else onConfirm(ready);
|
||||
} else onConfirm(ready, replacements.length > 0);
|
||||
}}
|
||||
>
|
||||
{step === "review"
|
||||
|
||||
@@ -2391,6 +2391,7 @@ export function StudioClient({
|
||||
{review && (
|
||||
<ImportReview
|
||||
items={review.items}
|
||||
sourceId={activeSource || undefined}
|
||||
source={
|
||||
sources.find((entry) => entry.id === activeSource)?.name ??
|
||||
source.label
|
||||
@@ -2403,10 +2404,10 @@ export function StudioClient({
|
||||
: "Off"
|
||||
}
|
||||
onCancel={() => setReview(null)}
|
||||
onConfirm={(accepted) => {
|
||||
onConfirm={(accepted, replaced) => {
|
||||
const single = review.single;
|
||||
setReview(null);
|
||||
if (review.cloneAll)
|
||||
if (review.cloneAll && !replaced)
|
||||
void cloneAllMissing(accepted.map((item) => item.classname));
|
||||
else if (single && accepted[0]) void importSingle(accepted[0]);
|
||||
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");
|
||||
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 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 ────────────────────────────────────────
|
||||
const rev = bestRevision;
|
||||
const assetTargets = await getFurniAssetWriteTargets();
|
||||
@@ -828,7 +789,7 @@ export async function importSingleFurni(params: {
|
||||
.catch(() => null);
|
||||
const nitroValid = nitroStat && nitroStat.size >= 128;
|
||||
|
||||
if (!nitroValid && !conversionResult) {
|
||||
if (!nitroValid) {
|
||||
if (nitroStat) {
|
||||
warnings.push(
|
||||
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
|
||||
@@ -839,24 +800,56 @@ export async function importSingleFurni(params: {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!isUpdate)
|
||||
await db.execute(
|
||||
sql`DELETE FROM items_base WHERE id = ${itemId} AND item_name = ${classname}`,
|
||||
);
|
||||
} catch {
|
||||
/* best effort cleanup */
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
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))
|
||||
.slice(-3)
|
||||
.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([
|
||||
mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings),
|
||||
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