fix(catalog): remove suggestions that substitute different furniture
This commit is contained in:
1 parent
33b6d1520e
commit
2d14e02a68
5 files changed
+15
-139
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user