diff --git a/src/app/api/admin/import/furni/resync/route.test.ts b/src/app/api/admin/import/furni/resync/route.test.ts new file mode 100644 index 00000000..0f5cdc58 --- /dev/null +++ b/src/app/api/admin/import/furni/resync/route.test.ts @@ -0,0 +1,175 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { emptyRouteContext } from "@/test/route-context"; + +const state = vi.hoisted(() => ({ + localIndex: new Map>(), + rows: [] as Array>, + append: vi.fn(async (_entries: unknown[]) => {}), + clear: vi.fn(), + rconCatalog: vi.fn(async () => true), + rconItems: vi.fn(async () => true), +})); + +vi.mock("@/lib/api-handler", () => ({ + withAdmin: (_options: unknown, handler: unknown) => handler, +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + inArray: (...args: unknown[]) => ({ op: "in", args }), + asc: (col: unknown) => ({ op: "asc", col }), + }; +}); + +vi.mock("@/lib/db", () => ({ + db: { + select: () => ({ + from: () => ({ + where: async () => state.rows, + orderBy: async () => state.rows, + }), + }), + }, + ItemsBase: { + id: "id", + spriteId: "spriteId", + itemName: "itemName", + publicName: "publicName", + type: "type", + width: "width", + length: "length", + stackHeight: "stackHeight", + }, + queryRows: vi.fn(async () => []), +})); + +vi.mock("@/lib/services/furni-import-cache", () => ({ + getLocalCatalogSnapshot: async () => ({ + localIndex: state.localIndex, + importedSet: new Set(state.localIndex.keys()), + timestamp: 0, + }), + clearFurniImportCache: state.clear, +})); + +vi.mock("@/lib/services/furni-data", () => ({ + appendFurniEntriesBatch: state.append, + buildFurniEntry: (params: { classname: string; id: number }) => ({ + classname: params.classname, + id: params.id, + }), + getFurnitureDataPath: async () => "/nonexistent/furnidata.json", + readFurniData: async () => ({}), +})); + +vi.mock("@/lib/services/habbo-furnidata-cache", () => ({ + lookupOfficialHabboFurni: async () => null, +})); + +vi.mock("@/lib/services/rcon", () => ({ + rcon: { + updateCatalog: state.rconCatalog, + updateItems: state.rconItems, + }, +})); + +import { POST } from "./route"; + +const post = (body?: unknown) => + new NextRequest("http://localhost/api/admin/import/furni/resync", { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + }); + +beforeEach(() => { + state.localIndex = new Map([ + ["chair", [{ id: 1, itemName: "chair" }]], + ["table", [{ id: 2, itemName: "table" }]], + ]); + state.rows = [ + { id: 1, itemName: "chair", spriteId: 1, type: "s" }, + { id: 2, itemName: "table", spriteId: 2, type: "s" }, + ]; + state.append.mockClear(); + state.clear.mockClear(); + state.rconCatalog.mockClear(); + state.rconItems.mockClear(); +}); + +describe("resync with an explicit classname selection", () => { + it("resyncs only the rows matching the sent classnames", async () => { + const response = await POST( + post({ classnames: ["chair", "table", "ghost"] }), + emptyRouteContext(), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + mode: "classnames", + examined: 2, + resynced: 2, + failed: 0, + rconOk: true, + }); + expect(state.append).toHaveBeenCalledTimes(1); + const entries = state.append.mock.calls[0]?.[0] as Array<{ + entry: { classname: string }; + }>; + expect(entries.map((e) => e.entry.classname).sort()).toEqual([ + "chair", + "table", + ]); + }); + + it("matches classnames case-insensitively", async () => { + const response = await POST( + post({ classnames: ["CHAIR", " "] }), + emptyRouteContext(), + ); + const body = await response.json(); + expect(body.examined).toBe(1); + }); + + it("drops the cached classname set so the listing recomputes", async () => { + await POST(post({ classnames: ["chair"] }), emptyRouteContext()); + expect(state.clear).toHaveBeenCalledTimes(1); + }); + + it("is a no-op for an empty selection", async () => { + const response = await POST(post({ classnames: [] }), emptyRouteContext()); + const body = await response.json(); + expect(body).toMatchObject({ examined: 0, resynced: 0 }); + expect(state.append).not.toHaveBeenCalled(); + expect(state.clear).not.toHaveBeenCalled(); + expect(state.rconCatalog).not.toHaveBeenCalled(); + }); +}); + +describe("resync query-param modes", () => { + it("falls back to the default days mode when no body is sent", async () => { + const response = await POST(post(), emptyRouteContext()); + // post() sends no JSON at all, so request.json() must reject and be + // swallowed rather than turning the request into a targeted resync. + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.mode).toBe("days"); + expect(state.append).not.toHaveBeenCalled(); + }); + + it("still honours the missing mode via query params", async () => { + const response = await POST( + new NextRequest( + "http://localhost/api/admin/import/furni/resync?missing=1", + { method: "POST" }, + ), + emptyRouteContext(), + ); + const body = await response.json(); + expect(body.mode).toBe("missing"); + expect(state.append).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts index e014a24a..16eac70e 100644 --- a/src/app/api/admin/import/furni/resync/route.ts +++ b/src/app/api/admin/import/furni/resync/route.ts @@ -3,24 +3,31 @@ import { asc, inArray, sql } from "drizzle-orm"; import { apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { db, ItemsBase, queryRows } from "@/lib/db"; -import { PERMS } from "@/lib/permissions"; +import { normalizeClassname } from "@/lib/furni/classname"; +import { PERMS } from "@/lib/permission-slugs"; import { appendFurniEntriesBatch, buildFurniEntry, getFurnitureDataPath, readFurniData, } from "@/lib/services/furni-data"; +import { + clearFurniImportCache, + getLocalCatalogSnapshot, +} from "@/lib/services/furni-import-cache"; import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache"; import { rcon } from "@/lib/services/rcon"; /** * POST /api/admin/import/furni/resync * - * Modes (query params, pick one): + * Modes (pick one): * ?days=N — items imported in the last N days (from admin_audit_log). * ?missing=1 — every items_base row whose classname is absent from * FurnitureData.json (authoritative for "what's broken"). * ?all=1 — every items_base row (rebuilds entries for all imports). + * body — { classnames: string[] } targets just those rows, which is + * what the Studio "Add missing furnidata" bulk action uses. * * Default: ?days=7 * @@ -31,21 +38,40 @@ export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { const qp = request.nextUrl.searchParams; - const mode: "days" | "missing" | "broken" | "all" = - qp.get("all") === "1" - ? "all" - : qp.get("broken") === "1" - ? "broken" - : qp.get("missing") === "1" - ? "missing" - : "days"; + + // Targeted mode: the Studio sends the classnames of the selected rows so + // only those get an entry. Everything else keeps using the query params. + const body = (await request.json().catch(() => null)) as { + classnames?: unknown; + } | null; + const classnames = Array.isArray(body?.classnames) + ? body.classnames + .filter((c): c is string => typeof c === "string") + .map((c) => c.trim()) + .filter(Boolean) + : null; const days = Math.max( 1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365), ); - const targetIds = await resolveTargetIds(mode, days); + let mode: "days" | "missing" | "broken" | "all" | "classnames"; + let targetIds: number[]; + if (classnames != null) { + mode = "classnames"; + targetIds = await resolveClassnames(classnames); + } else { + mode = + qp.get("all") === "1" + ? "all" + : qp.get("broken") === "1" + ? "broken" + : qp.get("missing") === "1" + ? "missing" + : "days"; + targetIds = await resolveTargetIds(mode, days); + } if (targetIds.length === 0) { return apiOk({ @@ -133,6 +159,10 @@ export const POST = withAdmin( } } + // FurnitureData.json changed — drop the Studio's cached classname set so + // the next listing recomputes `hasFurnidata` instead of serving stale hits. + clearFurniImportCache(); + let rconOk = false; try { const a = await rcon.updateCatalog(); @@ -154,6 +184,18 @@ export const POST = withAdmin( }, ); +async function resolveClassnames(classnames: string[]): Promise { + if (classnames.length === 0) return []; + const { localIndex } = await getLocalCatalogSnapshot(); + const ids = new Set(); + for (const classname of classnames) { + for (const row of localIndex.get(normalizeClassname(classname)) ?? []) { + ids.add(row.id); + } + } + return [...ids]; +} + async function resolveTargetIds( mode: "days" | "missing" | "broken" | "all", days: number, diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 32da1a2f..04ec7d38 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -8,6 +8,7 @@ import { Check, CircleAlert, CloudDownload, + FileJson2, Globe, Languages, Layers, @@ -1145,6 +1146,7 @@ export function StudioClient({ const [bulkDeleting, setBulkDeleting] = useState(false); const [bulkRegenning, setBulkRegenning] = useState(false); + const [bulkFurnidata, setBulkFurnidata] = useState(false); async function bulkDeleteSelected() { if (selectedImported.length === 0) return; @@ -1241,6 +1243,46 @@ export function StudioClient({ } } + async function bulkAddFurnidata() { + if (selectedMissingFurnidata.length === 0) return; + setBulkFurnidata(true); + const wanted = selectedMissingFurnidata.map((i) => i.classname); + try { + const res = await adminFetch("/api/admin/import/furni/resync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classnames: wanted }), + }); + const data = await res.json(); + if (res.ok) { + const failedClassnames = new Set( + ((data.errors ?? []) as Array<{ classname: string }>).map( + (e) => e.classname, + ), + ); + const fixedClassnames = new Set( + wanted.filter((cn) => !failedClassnames.has(cn)), + ); + setItems((prev) => + prev.map((i) => + fixedClassnames.has(i.classname) ? { ...i, hasFurnidata: true } : i, + ), + ); + const fixed = Number(data.resynced ?? fixedClassnames.size); + const failed = Number(data.failed ?? 0); + toast.success( + `Furnidata added: ${fixed}${failed > 0 ? `, failed: ${failed}` : ""}`, + ); + } else { + toast.error(data.error || "Adding furnidata failed"); + } + } catch { + toast.error("Network error"); + } finally { + setBulkFurnidata(false); + } + } + const selectedNonImported = useMemo( () => filteredItems.filter( @@ -1255,6 +1297,13 @@ export function StudioClient({ [filteredItems], ); + const missingFurnidataCount = useMemo( + () => + filteredItems.filter((i) => i.alreadyImported && i.hasFurnidata === false) + .length, + [filteredItems], + ); + const selectedImported = useMemo( () => filteredItems.filter( @@ -1268,6 +1317,11 @@ export function StudioClient({ [selectedImported], ); + const selectedMissingFurnidata = useMemo( + () => selectedImported.filter((i) => i.hasFurnidata === false), + [selectedImported], + ); + const notImportedCount = useMemo( () => activeSource && cloneStats @@ -1556,6 +1610,21 @@ export function StudioClient({ {missingNitroCount} no nitro )} + {missingFurnidataCount > 0 && ( + + )}
+ )} + {selectedImported.length > 0 && !batchProgress && (