fix(catalog): remove suggestions that substitute different furniture
CI / check (push) Successful in 1m43s
CI / deploy (push) Successful in 1m27s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 15:26:46 +02:00
1 parent 33b6d1520e
commit 2d14e02a68
5 files changed
+15 -139

No files matched your search

+6 -72
View File
@@ -19,7 +19,7 @@ import type { FurniItem } from "./studio-types";
import { useSourceAssetChecks } from "./use-source-asset-checks";
export function ImportReview({
items: originalItems,
items,
sourceId,
source,
translation,
@@ -31,10 +31,8 @@ export function ImportReview({
source: string;
translation: string;
onCancel: () => void;
onConfirm: (items: FurniItem[], replaced?: boolean) => void;
onConfirm: (items: FurniItem[]) => void;
}) {
const [items, setItems] = useState(originalItems);
const [replacements, setReplacements] = useState<string[]>([]);
const inspection = useFurnitureInspection(
items.map((item) => item.classname),
);
@@ -112,22 +110,6 @@ 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
@@ -156,58 +138,10 @@ export function ImportReview({
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.
The source has no downloadable files for this furniture. It
cannot be imported until its original .nitro or source files are
available.
</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 ? (
@@ -380,7 +314,7 @@ export function ImportReview({
if (step === "review") {
setStep("confirm");
setPage(0);
} else onConfirm(ready, replacements.length > 0);
} else onConfirm(ready);
}}
>
{step === "review"
@@ -2419,10 +2419,10 @@ export function StudioClient({
: "Off"
}
onCancel={() => setReview(null)}
onConfirm={(accepted, replaced) => {
onConfirm={(accepted) => {
const single = review.single;
setReview(null);
if (review.cloneAll && !replaced)
if (review.cloneAll)
void cloneAllMissing(accepted.map((item) => item.classname));
else if (single && accepted[0]) void importSingle(accepted[0]);
else
-24
View File
@@ -14,27 +14,3 @@ export interface SourceAssetCheck {
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);
}
@@ -1,9 +1,6 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import {
relatedFurniture,
type SourceAssetItem,
} from "@/lib/furni/source-assets";
import type { SourceAssetItem } from "@/lib/furni/source-assets";
import { inspectSourceAssets, probeAsset } from "./furniture-source-assets";
const source: SourceAssetItem = {
@@ -23,7 +20,7 @@ const alternative = {
};
afterEach(() => vi.unstubAllGlobals());
describe("source asset preflight", () => {
it("suggests reachable alternatives for unavailable source files", async () => {
it("keeps missing furniture unchanged even when a similar asset is available", async () => {
const fetchMock = vi.fn(
async (url: string) =>
new Response(null, { status: url.includes("/73257/") ? 200 : 404 }),
@@ -31,7 +28,9 @@ describe("source asset preflight", () => {
vi.stubGlobal("fetch", fetchMock);
const result = await inspectSourceAssets(source, [source, alternative]);
expect(result.state).toBe("missing");
expect(result.alternatives).toEqual([alternative]);
expect(result.classname).toBe(source.classname);
expect(result.alternatives).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not describe blocked or unreachable servers as missing files", async () => {
vi.stubGlobal(
@@ -67,28 +66,6 @@ describe("source asset preflight", () => {
);
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);
+2 -13
View File
@@ -3,7 +3,6 @@ import type {
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";
@@ -78,21 +77,11 @@ export async function inspectSourceAssets(
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,
alternatives: [],
};
}