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"; import { useSourceAssetChecks } from "./use-source-asset-checks";
export function ImportReview({ export function ImportReview({
items: originalItems, items,
sourceId, sourceId,
source, source,
translation, translation,
@@ -31,10 +31,8 @@ export function ImportReview({
source: string; source: string;
translation: string; translation: string;
onCancel: () => void; 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( const inspection = useFurnitureInspection(
items.map((item) => item.classname), items.map((item) => item.classname),
); );
@@ -112,22 +110,6 @@ 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 && ( {assets.loading && !inspection.loading && (
<p role="status"> <p role="status">
Checking source files… {assets.items.length} checked Checking source files… {assets.items.length} checked
@@ -156,58 +138,10 @@ export function ImportReview({
Source files unavailable: {check.classname} Source files unavailable: {check.classname}
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
This item will be skipped. Choose a reachable alternative below The source has no downloadable files for this furniture. It
to replace it in this import. cannot be imported until its original .nitro or source files are
available.
</p> </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> </section>
))} ))}
{inspection.loading ? ( {inspection.loading ? (
@@ -380,7 +314,7 @@ export function ImportReview({
if (step === "review") { if (step === "review") {
setStep("confirm"); setStep("confirm");
setPage(0); setPage(0);
} else onConfirm(ready, replacements.length > 0); } else onConfirm(ready);
}} }}
> >
{step === "review" {step === "review"
@@ -2419,10 +2419,10 @@ export function StudioClient({
: "Off" : "Off"
} }
onCancel={() => setReview(null)} onCancel={() => setReview(null)}
onConfirm={(accepted, replaced) => { onConfirm={(accepted) => {
const single = review.single; const single = review.single;
setReview(null); setReview(null);
if (review.cloneAll && !replaced) if (review.cloneAll)
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
-24
View File
@@ -14,27 +14,3 @@ export interface SourceAssetCheck {
revision: number; revision: number;
alternatives: SourceAssetItem[]; 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 // @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import type { SourceAssetItem } from "@/lib/furni/source-assets";
relatedFurniture,
type SourceAssetItem,
} from "@/lib/furni/source-assets";
import { inspectSourceAssets, probeAsset } from "./furniture-source-assets"; import { inspectSourceAssets, probeAsset } from "./furniture-source-assets";
const source: SourceAssetItem = { const source: SourceAssetItem = {
@@ -23,7 +20,7 @@ const alternative = {
}; };
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
describe("source asset preflight", () => { 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( const fetchMock = vi.fn(
async (url: string) => async (url: string) =>
new Response(null, { status: url.includes("/73257/") ? 200 : 404 }), new Response(null, { status: url.includes("/73257/") ? 200 : 404 }),
@@ -31,7 +28,9 @@ describe("source asset preflight", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const result = await inspectSourceAssets(source, [source, alternative]); const result = await inspectSourceAssets(source, [source, alternative]);
expect(result.state).toBe("missing"); 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 () => { it("does not describe blocked or unreachable servers as missing files", async () => {
vi.stubGlobal( vi.stubGlobal(
@@ -67,28 +66,6 @@ describe("source asset preflight", () => {
); );
expect(result.state).toBe("available"); 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 () => { it("caches a probe to avoid repeating CDN requests", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
+2 -13
View File
@@ -3,7 +3,6 @@ import type {
SourceAssetCheck, SourceAssetCheck,
SourceAssetItem, SourceAssetItem,
} from "@/lib/furni/source-assets"; } from "@/lib/furni/source-assets";
import { relatedFurniture } from "@/lib/furni/source-assets";
import type { CloneSource } from "./clone-sources"; import type { CloneSource } from "./clone-sources";
import { browserHeaders } from "./import/core/browser-headers"; import { browserHeaders } from "./import/core/browser-headers";
@@ -78,21 +77,11 @@ export async function inspectSourceAssets(
revision: item.revision || canonical?.revision || 0, revision: item.revision || canonical?.revision || 0,
}; };
const state = await available(effective, source); 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 { return {
classname: item.classname, classname: item.classname,
revision: effective.revision, revision: effective.revision,
state, state,
alternatives, alternatives: [],
}; };
} }