diff --git a/src/app/api/admin/studio/source-assets/route.test.ts b/src/app/api/admin/studio/source-assets/route.test.ts new file mode 100644 index 0000000000..59fe40a4cc --- /dev/null +++ b/src/app/api/admin/studio/source-assets/route.test.ts @@ -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"); +}); diff --git a/src/app/api/admin/studio/source-assets/route.ts b/src/app/api/admin/studio/source-assets/route.ts new file mode 100644 index 0000000000..a0e024312a --- /dev/null +++ b/src/app/api/admin/studio/source-assets/route.ts @@ -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 }); + }, +); diff --git a/src/components/admin/studio/import-review.tsx b/src/components/admin/studio/import-review.tsx index 451d4505bc..1e662c730b 100644 --- a/src/components/admin/studio/import-review.tsx +++ b/src/components/admin/studio/import-review.tsx @@ -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([]); 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({ Selection: {items.length} furniture items

+ {replacements.length > 0 && ( +

+ Replacements selected: {replacements.join(" · ")}{" "} + +

+ )} + {assets.loading && !inspection.loading && ( +

+ Checking source files… {assets.items.length} checked +

+ )} + {assets.error && ( +
+

{assets.error}

+ +
+ )} + {assets.items.some((item) => item.state === "unknown") && ( +

+ Some sources could not be verified. The import will still try to + download their files. +

+ )} + {unavailable.map((check) => ( +
+

+ Source files unavailable: {check.classname} +

+

+ This item will be skipped. Choose a reachable alternative below + to replace it in this import. +

+ {check.alternatives.length === 0 && ( +

+ No verified alternative found. Upload the original .nitro from + the import page if you have it. +

+ )} + {check.alternatives.map((alternative) => ( +
+
+ {alternative.name} +

{alternative.classname}

+

+ {alternative.type} · Revision {alternative.revision} · ID{" "} + {alternative.id} +

+
+ +
+ ))} +
+ ))} {inspection.loading ? (

Checking local data and assets…

) : inspection.error ? ( @@ -115,7 +227,8 @@ export function ImportReview({ <>

{ready.length} ready to import · {existing} already in SQL - (complete missing data) · {blocked} conflicts (skipped) + (complete missing data) · {blocked} conflicts ·{" "} + {unavailable.length} unavailable (skipped)

{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" diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 4c6324267a..298916e094 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -2391,6 +2391,7 @@ export function StudioClient({ {review && ( 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 diff --git a/src/components/admin/studio/use-source-asset-checks.ts b/src/components/admin/studio/use-source-asset-checks.ts new file mode 100644 index 0000000000..0eb583b9f6 --- /dev/null +++ b/src/components/admin/studio/use-source-asset-checks.ts @@ -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), + }; +} diff --git a/src/lib/furni/source-assets.ts b/src/lib/furni/source-assets.ts new file mode 100644 index 0000000000..f0399bc0ee --- /dev/null +++ b/src/lib/furni/source-assets.ts @@ -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); +} diff --git a/src/lib/services/furni-import-repair.test.ts b/src/lib/services/furni-import-repair.test.ts index 79314b180c..5f6064b710 100644 --- a/src/lib/services/furni-import-repair.test.ts +++ b/src/lib/services/furni-import-repair.test.ts @@ -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(); +}); diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 35f2e493fc..93e41389cd 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -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( diff --git a/src/lib/services/furniture-source-assets.test.ts b/src/lib/services/furniture-source-assets.test.ts new file mode 100644 index 0000000000..d2324ac68c --- /dev/null +++ b/src/lib/services/furniture-source-assets.test.ts @@ -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); + }); +}); diff --git a/src/lib/services/furniture-source-assets.ts b/src/lib/services/furniture-source-assets.ts new file mode 100644 index 0000000000..90482fac5b --- /dev/null +++ b/src/lib/services/furniture-source-assets.ts @@ -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(); +export async function probeAsset(url: string): Promise { + 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 { + 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 { + 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, + }; +}