From c7891b60c3556ccd29dc5edb96df3b45b5277513 Mon Sep 17 00:00:00 2001
From: simoleo89
Date: Tue, 1 Sep 2026 19:04:48 +0200
Subject: [PATCH] feat(housekeeping): adapt catalog studio workflow
---
src/app/api/admin/import/furni/route.ts | 41 +--
.../hotel/commands/studio-commands.test.ts | 132 +++++++
.../domains/hotel/commands/studio-commands.ts | 284 +++++++++++++--
.../components/operation-progress.test.tsx | 22 ++
.../hotel/components/operation-progress.tsx | 35 +-
.../domains/hotel/pages/studio.test.tsx | 126 +++++++
.../domains/hotel/pages/studio.tsx | 324 +++++++++++++++++-
.../domains/hotel/queries/studio.test.ts | 35 ++
.../domains/hotel/queries/studio.ts | 159 ++++++++-
src/lib/services/clone-inventory.test.ts | 97 ++++++
src/lib/services/clone-inventory.ts | 135 ++++++++
11 files changed, 1305 insertions(+), 85 deletions(-)
create mode 100644 src/lib/services/clone-inventory.test.ts
create mode 100644 src/lib/services/clone-inventory.ts
diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts
index 5c66de76..d76b2476 100644
--- a/src/app/api/admin/import/furni/route.ts
+++ b/src/app/api/admin/import/furni/route.ts
@@ -6,7 +6,7 @@ import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
-import { getCloneList } from "@/lib/services/clone-import";
+import { getCloneInventoryStatus } from "@/lib/services/clone-inventory";
import { getSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
@@ -303,7 +303,7 @@ export const GET = withAdmin(
const source = await getSource(sourceId);
if (!source) return apiError("Source not found", 404);
- const { items, meta } = await getCloneList({
+ const { items, meta } = await getCloneInventoryStatus({
source,
search,
page,
@@ -313,37 +313,6 @@ export const GET = withAdmin(
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
- // Bulk-load FurnitureData and catalog references for status checks
- const furniDataSet = new Set();
- const catalogIdSet = new Set();
- try {
- const fd = (await readFurniData()) as {
- roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
- wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
- };
- for (const e of [
- ...(fd.roomitemtypes?.furnitype ?? []),
- ...(fd.wallitemtypes?.furnitype ?? []),
- ]) {
- if (e.classname) furniDataSet.add(e.classname);
- }
- } catch {
- /* best effort */
- }
- try {
- const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`);
- for (const row of (
- rows as unknown as [Array<{ item_ids: string | null }>, unknown]
- )[0]) {
- for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
- const n = Number(part.trim());
- if (Number.isFinite(n) && n > 0) catalogIdSet.add(n);
- }
- }
- } catch {
- /* best effort */
- }
-
const enriched = items.map((item) => {
const imported = item.present;
let nitroExists = false;
@@ -368,9 +337,9 @@ export const GET = withAdmin(
revision: 0,
category: String(item.category ?? ""),
alreadyImported: imported,
- nitroExists,
- hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
- hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
+ nitroExists: item.nitroExists ?? nitroExists,
+ hasFurnidata: item.hasFurnidata,
+ hasCatalogEntry: item.hasCatalogEntry,
iconUrl: sourceIconBase
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
: undefined,
diff --git a/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts b/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts
index a5360c4a..e117fc07 100644
--- a/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts
+++ b/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts
@@ -10,6 +10,57 @@ import {
} from "./studio-commands";
const importBadgeSynced = vi.hoisted(() => vi.fn());
+const cloneServices = vi.hoisted(() => ({
+ listSources: vi.fn(),
+ getClonableClassnames: vi.fn(),
+ fetchSourceFurnidata: vi.fn(),
+ cloneSingleFurni: vi.fn(),
+ appendFurniEntriesBatch: vi.fn(),
+ appendFurniEntry: vi.fn(),
+ reconcileImportedOfferIds: vi.fn(),
+ reconcileFurniDataWithItemsBase: vi.fn(),
+ rebuildCatalogOfferIds: vi.fn(),
+ verifyAndFixSpriteIds: vi.fn(),
+ verifyAndFixInteractionModesCount: vi.fn(),
+ buildLocalizedFurniDataFiles: vi.fn(),
+ ensureFurniOwnership: vi.fn(),
+ syncAssetsToGamedataBundle: vi.fn(),
+ updateCatalog: vi.fn(),
+ updateItems: vi.fn(),
+}));
+
+vi.mock("@/lib/services/clone-sources", () => ({
+ listSources: cloneServices.listSources,
+}));
+vi.mock("@/lib/services/clone-import", () => ({
+ getClonableClassnames: cloneServices.getClonableClassnames,
+ fetchSourceFurnidata: cloneServices.fetchSourceFurnidata,
+ cloneSingleFurni: cloneServices.cloneSingleFurni,
+}));
+vi.mock("@/lib/services/furni-data", () => ({
+ appendFurniEntriesBatch: cloneServices.appendFurniEntriesBatch,
+ appendFurniEntry: cloneServices.appendFurniEntry,
+ reconcileFurniDataWithItemsBase:
+ cloneServices.reconcileFurniDataWithItemsBase,
+ rebuildCatalogOfferIds: cloneServices.rebuildCatalogOfferIds,
+ verifyAndFixSpriteIds: cloneServices.verifyAndFixSpriteIds,
+}));
+vi.mock("@/lib/services/furni-data-i18n", () => ({
+ buildLocalizedFurniDataFiles: cloneServices.buildLocalizedFurniDataFiles,
+}));
+vi.mock("@/lib/services/furni-import", () => ({
+ reconcileImportedOfferIds: cloneServices.reconcileImportedOfferIds,
+ verifyAndFixInteractionModesCount:
+ cloneServices.verifyAndFixInteractionModesCount,
+ ensureFurniOwnership: cloneServices.ensureFurniOwnership,
+ syncAssetsToGamedataBundle: cloneServices.syncAssetsToGamedataBundle,
+}));
+vi.mock("@/lib/services/rcon", () => ({
+ rcon: {
+ updateCatalog: cloneServices.updateCatalog,
+ updateItems: cloneServices.updateItems,
+ },
+}));
vi.mock("@/lib/services/import-badge", () => ({ importBadgeSynced }));
@@ -89,6 +140,87 @@ describe("Studio commands", () => {
controller.signal,
);
});
+
+ it("clones every missing item, reports durable progress and retains retry data", async () => {
+ cloneServices.listSources.mockResolvedValueOnce([
+ {
+ id: "hotel-nl",
+ name: "Hotel NL",
+ furnidataUrl: "https://hotel.test/furnidata.json",
+ nitroBaseUrl: "https://hotel.test/nitro",
+ iconBaseUrl: "https://hotel.test/icons",
+ hotel: "nl",
+ },
+ ]);
+ cloneServices.getClonableClassnames.mockResolvedValueOnce([
+ "modern_chair",
+ "broken_lamp",
+ "modern_chair",
+ ]);
+ cloneServices.fetchSourceFurnidata.mockResolvedValueOnce([
+ { classname: "modern_chair", itemType: "s" },
+ { classname: "broken_lamp", itemType: "s" },
+ ]);
+ cloneServices.cloneSingleFurni.mockImplementation(async ({ entry }) =>
+ entry.classname === "modern_chair"
+ ? {
+ ok: true,
+ classname: entry.classname,
+ warnings: [],
+ furniDataEntry: {
+ entry: { classname: entry.classname },
+ itemType: "s",
+ },
+ }
+ : {
+ ok: false,
+ classname: entry.classname,
+ warnings: [],
+ error: "download failed",
+ },
+ );
+ const report = vi.fn();
+
+ await expect(
+ studioOperationRunner.run(
+ "clone",
+ {
+ mode: "all-missing",
+ sourceId: "hotel-nl",
+ translate: true,
+ langs: ["nl", "en"],
+ concurrency: 2,
+ },
+ {
+ operationId: "op-clone-all",
+ correlationId: "corr-clone-all",
+ actorId: 42,
+ report,
+ },
+ ),
+ ).resolves.toMatchObject({
+ phase: "partial",
+ completed: 2,
+ total: 2,
+ output: {
+ sourceId: "hotel-nl",
+ succeeded: 1,
+ failed: 1,
+ skipped: 0,
+ failedClassnames: ["broken_lamp"],
+ },
+ });
+ expect(report.mock.calls.map(([progress]) => progress.completed)).toEqual([
+ 1, 2,
+ ]);
+ expect(cloneServices.appendFurniEntriesBatch).toHaveBeenCalledTimes(1);
+ expect(cloneServices.buildLocalizedFurniDataFiles).toHaveBeenCalledWith(
+ true,
+ ["nl", "en"],
+ );
+ expect(cloneServices.updateCatalog).toHaveBeenCalledTimes(1);
+ expect(cloneServices.updateItems).toHaveBeenCalledTimes(1);
+ });
});
describe("Studio operation service", () => {
diff --git a/src/features/housekeeping/domains/hotel/commands/studio-commands.ts b/src/features/housekeeping/domains/hotel/commands/studio-commands.ts
index 6c3c2fd6..d7d0e451 100644
--- a/src/features/housekeeping/domains/hotel/commands/studio-commands.ts
+++ b/src/features/housekeeping/domains/hotel/commands/studio-commands.ts
@@ -341,6 +341,256 @@ function normalizedResult(value: unknown): StudioOperationRunnerResult {
};
}
+function stringList(value: unknown): string[] {
+ const values = Array.isArray(value)
+ ? value
+ : typeof value === "string"
+ ? value.split(",")
+ : [];
+ return [
+ ...new Set(
+ values
+ .filter((item): item is string => typeof item === "string")
+ .map((item) => item.normalize("NFC").trim())
+ .filter(Boolean),
+ ),
+ ];
+}
+
+function cloneConcurrency(value: unknown): number {
+ const parsed = Number(value ?? 3);
+ return Number.isSafeInteger(parsed) ? Math.min(10, Math.max(1, parsed)) : 3;
+}
+
+async function runCloneOperation(
+ input: Readonly>,
+ execution: StudioOperationExecution,
+): Promise {
+ const [cloneSources, cloneImport] = await Promise.all([
+ import("@/lib/services/clone-sources"),
+ import("@/lib/services/clone-import"),
+ ]);
+ const sourceId = requiredText(input, "sourceId");
+ const source = (await cloneSources.listSources()).find(
+ (item) => item.id === sourceId,
+ );
+ if (!source) {
+ throw new StudioOperationFailure(
+ "DEPENDENCY_UNAVAILABLE",
+ "errors.housekeeping.dependencyUnavailable",
+ );
+ }
+
+ const mode = typeof input.mode === "string" ? input.mode : "single";
+ let classnames: string[];
+ if (mode === "all-missing") {
+ classnames = stringList(await cloneImport.getClonableClassnames(source));
+ } else if (mode === "retry") {
+ classnames = stringList(input.classnames);
+ if (classnames.length === 0) {
+ throw new StudioOperationFailure(
+ "VALIDATION",
+ "errors.housekeeping.validation",
+ );
+ }
+ } else if (mode === "single") {
+ classnames = [requiredText(input, "classname")];
+ } else {
+ throw new StudioOperationFailure(
+ "VALIDATION",
+ "errors.housekeeping.validation",
+ );
+ }
+
+ execution.signal?.throwIfAborted();
+ const sourceEntries = await cloneImport.fetchSourceFurnidata(
+ source.furnidataUrl,
+ Date.now(),
+ execution.signal,
+ );
+ const entriesByClassname = new Map(
+ sourceEntries.map((entry) => [entry.classname, entry] as const),
+ );
+ type CloneOutcome =
+ | {
+ readonly classname: string;
+ readonly result: Awaited<
+ ReturnType
+ >;
+ }
+ | { readonly classname: string; readonly error: string };
+ const outcomes: CloneOutcome[] = new Array(classnames.length);
+ let nextIndex = 0;
+ let completed = 0;
+ let reportChain = Promise.resolve();
+ const report = () => {
+ completed += 1;
+ const snapshot = completed;
+ reportChain = reportChain.then(() =>
+ execution.report({
+ completed: snapshot,
+ total: classnames.length,
+ messageKey: "pages.housekeeping.studio.progress.clone",
+ }),
+ );
+ };
+ const worker = async () => {
+ while (nextIndex < classnames.length) {
+ const index = nextIndex;
+ nextIndex += 1;
+ const classname = classnames[index];
+ const entry = entriesByClassname.get(classname);
+ if (!entry) {
+ outcomes[index] = { classname, error: "source entry unavailable" };
+ report();
+ continue;
+ }
+ try {
+ execution.signal?.throwIfAborted();
+ outcomes[index] = {
+ classname,
+ result: await cloneImport.cloneSingleFurni({
+ source,
+ entry,
+ signal: execution.signal,
+ deferFurniData: true,
+ }),
+ };
+ } catch (error) {
+ if (cancelled(execution.signal, error)) throw error;
+ outcomes[index] = {
+ classname,
+ error: error instanceof Error ? error.message : "clone failed",
+ };
+ }
+ report();
+ }
+ };
+ await Promise.all(
+ Array.from(
+ {
+ length: Math.min(
+ cloneConcurrency(input.concurrency),
+ classnames.length,
+ ),
+ },
+ worker,
+ ),
+ );
+ await reportChain;
+ execution.signal?.throwIfAborted();
+
+ const furniDataEntries: Array<{
+ entry: Record;
+ itemType: string;
+ }> = [];
+ const failedClassnames: string[] = [];
+ const warnings: string[] = [];
+ let succeeded = 0;
+ let skipped = 0;
+ for (const outcome of outcomes) {
+ if ("error" in outcome) {
+ failedClassnames.push(outcome.classname);
+ warnings.push(`${outcome.classname}: ${outcome.error}`);
+ continue;
+ }
+ warnings.push(
+ ...outcome.result.warnings.map(
+ (warning) => `${outcome.classname}: ${warning}`,
+ ),
+ );
+ if (outcome.result.skipped) {
+ skipped += 1;
+ } else if (outcome.result.ok) {
+ succeeded += 1;
+ if (outcome.result.furniDataEntry) {
+ furniDataEntries.push(outcome.result.furniDataEntry);
+ }
+ } else {
+ failedClassnames.push(outcome.classname);
+ warnings.push(
+ `${outcome.classname}: ${outcome.result.error ?? "clone failed"}`,
+ );
+ }
+ }
+
+ const [furniData, furniDataI18n, furniImport, { rcon }] = await Promise.all([
+ import("@/lib/services/furni-data"),
+ import("@/lib/services/furni-data-i18n"),
+ import("@/lib/services/furni-import"),
+ import("@/lib/services/rcon"),
+ ]);
+ if (furniDataEntries.length > 0) {
+ try {
+ await furniData.appendFurniEntriesBatch(furniDataEntries);
+ } catch (error) {
+ warnings.push(
+ `FurnitureData batch write failed: ${(error as Error).message}`,
+ );
+ for (const item of furniDataEntries) {
+ try {
+ await furniData.appendFurniEntry(item.entry, item.itemType);
+ } catch (fallbackError) {
+ warnings.push(
+ `FurnitureData fallback failed: ${(fallbackError as Error).message}`,
+ );
+ }
+ }
+ }
+ }
+ const consolidate = async (label: string, task: () => Promise) => {
+ try {
+ await task();
+ } catch (error) {
+ warnings.push(`${label}: ${(error as Error).message}`);
+ }
+ };
+ await consolidate(
+ "offer reconciliation",
+ furniImport.reconcileImportedOfferIds,
+ );
+ await consolidate(
+ "FurnitureData reconciliation",
+ furniData.reconcileFurniDataWithItemsBase,
+ );
+ await consolidate("catalog offer rebuild", furniData.rebuildCatalogOfferIds);
+ await consolidate("sprite ID verification", furniData.verifyAndFixSpriteIds);
+ await consolidate(
+ "interaction mode verification",
+ furniImport.verifyAndFixInteractionModesCount,
+ );
+ if (input.translate === true) {
+ const languages = stringList(input.langs);
+ await consolidate("FurnitureData translation", () =>
+ furniDataI18n.buildLocalizedFurniDataFiles(
+ true,
+ languages.length > 0 ? languages : undefined,
+ ),
+ );
+ }
+ await consolidate("asset ownership", furniImport.ensureFurniOwnership);
+ await consolidate("gamedata sync", () =>
+ furniImport.syncAssetsToGamedataBundle(execution.signal),
+ );
+ await consolidate("catalog refresh", () => rcon.updateCatalog());
+ await consolidate("item refresh", () => rcon.updateItems());
+
+ const failed = failedClassnames.length;
+ return {
+ phase: failed > 0 || warnings.length > 0 ? "partial" : "completed",
+ completed: classnames.length,
+ total: classnames.length,
+ output: {
+ sourceId,
+ succeeded,
+ failed,
+ skipped,
+ failedClassnames,
+ warnings,
+ },
+ };
+}
+
async function productionRun(
kind: StudioOperationKind,
rawInput: Readonly>,
@@ -370,39 +620,7 @@ async function productionRun(
execution.signal,
);
} else if (kind === "clone") {
- const [{ listSources }, { cloneSingleFurni, fetchSourceFurnidata }] =
- await Promise.all([
- import("@/lib/services/clone-sources"),
- import("@/lib/services/clone-import"),
- ]);
- const sourceId = requiredText(input, "sourceId");
- const classname = requiredText(input, "classname");
- const source = (await listSources()).find((item) => item.id === sourceId);
- if (!source) {
- throw new StudioOperationFailure(
- "DEPENDENCY_UNAVAILABLE",
- "errors.housekeeping.dependencyUnavailable",
- );
- }
- const entry = (
- await fetchSourceFurnidata(
- source.furnidataUrl,
- Date.now(),
- execution.signal,
- )
- ).find((item) => item.classname === classname);
- if (!entry) {
- throw new StudioOperationFailure(
- "NOT_FOUND",
- "errors.housekeeping.notFound",
- );
- }
- result = await cloneSingleFurni({
- source,
- entry,
- onProgress: progress,
- signal: execution.signal,
- });
+ return runCloneOperation(input, execution);
} else if (kind === "clothing") {
if (input.setType !== undefined || input.setId !== undefined) {
const { importClothingSet } = await import(
diff --git a/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx b/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx
index 6b94462b..8ef26131 100644
--- a/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx
+++ b/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx
@@ -23,6 +23,28 @@ describe("Studio operation components", () => {
expect(markup).toContain("2 / 4");
});
+ it("shows percentage and a stable ETA from persisted operation timestamps", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("50%");
+ expect(markup).toContain("Estimated remaining: 20s");
+ });
+
it("distinguishes partial and failed terminal results", () => {
const partial = renderToStaticMarkup(
0;
+ const percentage = determinate
+ ? Math.round((event.completed / (event.total ?? 1)) * 100)
+ : null;
+ let estimatedSeconds: number | null = null;
+ if (
+ determinate &&
+ event.completed > 0 &&
+ "createdAt" in event &&
+ "updatedAt" in event
+ ) {
+ const elapsedSeconds = Math.max(
+ 0,
+ (Date.parse(event.updatedAt) - Date.parse(event.createdAt)) / 1000,
+ );
+ estimatedSeconds = Math.round(
+ (elapsedSeconds / event.completed) *
+ ((event.total ?? 0) - event.completed),
+ );
+ }
return (
{event.completed}
{event.total === null ? " completed" : ` / ${event.total}`}
+ {percentage === null ? null : ` (${percentage}%)`}
+ {estimatedSeconds !== null && estimatedSeconds > 0 ? (
+
+ Estimated remaining:{" "}
+ {Math.floor(estimatedSeconds / 60) > 0
+ ? `${Math.floor(estimatedSeconds / 60)}m ${estimatedSeconds % 60}s`
+ : `${estimatedSeconds}s`}
+
+ ) : null}
);
}
diff --git a/src/features/housekeeping/domains/hotel/pages/studio.test.tsx b/src/features/housekeeping/domains/hotel/pages/studio.test.tsx
index 97c6b325..a40f2118 100644
--- a/src/features/housekeeping/domains/hotel/pages/studio.test.tsx
+++ b/src/features/housekeeping/domains/hotel/pages/studio.test.tsx
@@ -111,4 +111,130 @@ describe("Hotel Studio page", () => {
expect(markup).not.toContain("hotel.studio.maintenance.run");
expect(markup).not.toContain("Start maintenance operation");
});
+
+ it("renders a typed clone workspace with detailed status, clone-all and retry", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("Hotel NL");
+ expect(markup).toContain("Clone all missing (3)");
+ expect(markup).toContain("Modern chair");
+ expect(markup).toContain("Missing Nitro");
+ expect(markup).toContain("Missing catalog entry");
+ expect(markup).toContain("Retry failed items (2)");
+ expect(markup).toContain('name="classname"');
+ expect(markup).toContain('name="translate"');
+ expect(markup).not.toContain("Operation input (JSON)");
+ expect(markup).toContain('value="chair"');
+ expect(markup).toContain("itemPage=2");
+ });
+
+ it("does not present unknown clone diagnostics as ready", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).not.toContain("Ready");
+ });
+
+ it("uses route-specific fields instead of a generic JSON payload", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain('name="classname"');
+ expect(markup).toContain('name="revision"');
+ expect(markup).toContain('name="updateExisting"');
+ expect(markup).not.toContain("Operation input (JSON)");
+ });
});
diff --git a/src/features/housekeeping/domains/hotel/pages/studio.tsx b/src/features/housekeeping/domains/hotel/pages/studio.tsx
index 315a2d18..a8dc60b7 100644
--- a/src/features/housekeeping/domains/hotel/pages/studio.tsx
+++ b/src/features/housekeeping/domains/hotel/pages/studio.tsx
@@ -6,9 +6,16 @@ import type {
import type { HousekeepingPageInput } from "../../../route-handlers";
import { OperationProgress } from "../components/operation-progress";
import { OperationResult } from "../components/operation-result";
-import { type StudioQueryData, studioOperationQuery } from "../queries/studio";
+import {
+ type StudioCloneFilter,
+ type StudioCloneWorkspace,
+ type StudioOperationKind,
+ type StudioOperationRecord,
+ type StudioQueryData,
+ studioOperationQuery,
+} from "../queries/studio";
import { type HotelStudioRouteId, hotelStudioOperationKind } from "../routes";
-import { HotelCommandForm } from "./hotel-command-form";
+import { type HotelCommandField, HotelCommandForm } from "./hotel-command-form";
export interface StudioPageProps {
readonly context: HousekeepingCapabilityContext;
@@ -27,6 +34,281 @@ function canRun(
);
}
+const STUDIO_FIELDS: Record<
+ Exclude,
+ readonly HotelCommandField[]
+> = {
+ badge: [
+ { name: "code", label: "Badge code", type: "text", required: true },
+ { name: "name", label: "Badge name", type: "text", required: true },
+ { name: "description", label: "Description", type: "textarea" },
+ ],
+ clothing: [
+ { name: "lib", label: "Figure library", type: "text" },
+ { name: "setType", label: "Set type", type: "text" },
+ { name: "setId", label: "Set ID", type: "number" },
+ ],
+ effect: [
+ { name: "id", label: "Effect ID", type: "text", required: true },
+ { name: "lib", label: "Library", type: "text", required: true },
+ { name: "type", label: "Effect type", type: "text", required: true },
+ { name: "revision", label: "Revision", type: "number", required: true },
+ ],
+ furni: [
+ { name: "id", label: "Sprite ID", type: "number", required: true },
+ { name: "classname", label: "Classname", type: "text", required: true },
+ { name: "name", label: "Display name", type: "text", required: true },
+ { name: "description", label: "Description", type: "textarea" },
+ {
+ name: "type",
+ label: "Type (flooritem or wallitem)",
+ type: "text",
+ required: true,
+ },
+ { name: "revision", label: "Revision", type: "number", required: true },
+ { name: "category", label: "Category", type: "text", required: true },
+ { name: "updateExisting", label: "Update existing item", type: "checkbox" },
+ ],
+ maintenance: [
+ {
+ name: "dedupePages",
+ label: "Deduplicate catalog pages",
+ type: "checkbox",
+ },
+ ],
+ pet: [{ name: "lib", label: "Pet library", type: "text", required: true }],
+ "repair-icons": [
+ {
+ name: "repairNitros",
+ label: "Repair missing Nitro bundles too",
+ type: "checkbox",
+ },
+ ],
+ sync: [],
+ upload: [
+ { name: "classname", label: "Classname", type: "text", required: true },
+ { name: "name", label: "Display name", type: "text", required: true },
+ { name: "description", label: "Description", type: "textarea" },
+ {
+ name: "itemType",
+ label: "Item type (s or i)",
+ type: "text",
+ required: true,
+ },
+ {
+ name: "nitroBase64",
+ label: "Nitro bundle (base64)",
+ type: "textarea",
+ required: true,
+ maxLength: 10_000_000,
+ },
+ {
+ name: "iconBase64",
+ label: "Catalog icon (base64)",
+ type: "textarea",
+ maxLength: 2_000_000,
+ },
+ { name: "generateSql", label: "Generate SQL", type: "checkbox" },
+ ],
+};
+
+const CLONE_TRANSLATION_FIELDS: readonly HotelCommandField[] = [
+ {
+ name: "translate",
+ label: "Translate FurnitureData after import",
+ type: "checkbox",
+ },
+ {
+ name: "langs",
+ label: "Languages (comma separated, empty means all)",
+ type: "id-list",
+ },
+];
+
+function retryPayload(operation: StudioOperationRecord) {
+ const sourceId = operation.output?.sourceId;
+ const failedClassnames = operation.output?.failedClassnames;
+ if (typeof sourceId !== "string" || !Array.isArray(failedClassnames))
+ return null;
+ const classnames = failedClassnames.filter(
+ (classname): classname is string =>
+ typeof classname === "string" && Boolean(classname.trim()),
+ );
+ return classnames.length > 0 ? { sourceId, classnames } : null;
+}
+
+function cloneInventoryHref(workspace: StudioCloneWorkspace, page: number) {
+ const params = new URLSearchParams({
+ sourceId: workspace.selectedSource.id,
+ status: workspace.query.filter,
+ itemPage: String(page),
+ itemPageSize: String(workspace.meta.pageSize),
+ });
+ if (workspace.query.search) params.set("itemSearch", workspace.query.search);
+ return `?${params.toString()}`;
+}
+
+function CloneWorkspace({
+ workspace,
+ operations,
+}: {
+ readonly workspace?: StudioCloneWorkspace;
+ readonly operations: readonly StudioOperationRecord[];
+}) {
+ if (!workspace) {
+ return Clone inventory is temporarily unavailable.
;
+ }
+ const { selectedSource, stats } = workspace;
+ const lastPage = Math.max(
+ 1,
+ Math.ceil(workspace.meta.total / workspace.meta.pageSize),
+ );
+ return (
+
+
+
+
+ {stats.clonable > 0 ? (
+
+ ) : null}
+
+
+
+
+
+ | Furni |
+ Classname |
+ Status |
+
+
+
+ {workspace.items.map((item) => (
+
+ | {item.name} |
+ {item.classname} |
+
+ {!item.alreadyImported ? Not imported : null}
+ {item.nitroExists === false ? (
+ Missing Nitro
+ ) : null}
+ {item.hasFurnidata === false ? (
+ Missing FurnitureData
+ ) : null}
+ {item.hasCatalogEntry === false ? (
+ Missing catalog entry
+ ) : null}
+ {item.alreadyImported &&
+ (item.nitroExists === undefined ||
+ item.hasFurnidata === undefined ||
+ item.hasCatalogEntry === undefined) ? (
+ Status unavailable
+ ) : null}
+ {item.alreadyImported &&
+ item.nitroExists === true &&
+ item.hasFurnidata === true &&
+ item.hasCatalogEntry === true ? (
+ Ready
+ ) : null}
+ |
+
+ ))}
+
+
+
+
+ {operations.map((operation) => {
+ const retry = retryPayload(operation);
+ return retry ? (
+
+ ) : null;
+ })}
+
+ );
+}
+
export function StudioPage({ context, result, routeId }: StudioPageProps) {
if (!result) {
return (
@@ -52,7 +334,7 @@ export function StudioPage({ context, result, routeId }: StudioPageProps) {
const state =
result.data.partialDependencies.length > 0
? "partial"
- : result.data.items.length === 0
+ : result.data.items.length === 0 && !result.data.cloneWorkspace
? "empty"
: "ready";
const operationKind = routeId ? hotelStudioOperationKind(routeId) : null;
@@ -67,20 +349,21 @@ export function StudioPage({ context, result, routeId }: StudioPageProps) {
Temporarily unavailable: {result.data.partialDependencies.join(", ")}.
) : null}
- {routeId && operationKind && canRun(context, routeId) ? (
+ {routeId && operationKind === "clone" && canRun(context, routeId) ? (
+
+ ) : null}
+ {routeId &&
+ operationKind &&
+ operationKind !== "clone" &&
+ canRun(context, routeId) ? (
) : null}
@@ -104,6 +387,11 @@ function first(value: string | readonly string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
+function cloneFilter(value: string | undefined): StudioCloneFilter | undefined {
+ return value === "all" || value === "missing" || value === "present"
+ ? value
+ : undefined;
+}
export async function renderHotelStudioPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as HotelStudioRouteId;
const result = await studioOperationQuery.run(input.context, {
@@ -113,6 +401,16 @@ export async function renderHotelStudioPage(input: HousekeepingPageInput) {
pageSize: Number(first(input.searchParams?.pageSize) ?? 25),
offset: Number(first(input.searchParams?.offset) ?? 0),
},
+ clone:
+ hotelStudioOperationKind(routeId) === "clone"
+ ? {
+ sourceId: first(input.searchParams?.sourceId),
+ search: first(input.searchParams?.itemSearch),
+ filter: cloneFilter(first(input.searchParams?.status)),
+ page: Number(first(input.searchParams?.itemPage) ?? 1),
+ pageSize: Number(first(input.searchParams?.itemPageSize) ?? 25),
+ }
+ : undefined,
});
return (
diff --git a/src/features/housekeeping/domains/hotel/queries/studio.test.ts b/src/features/housekeeping/domains/hotel/queries/studio.test.ts
index 4c34ecd8..349118bf 100644
--- a/src/features/housekeeping/domains/hotel/queries/studio.test.ts
+++ b/src/features/housekeeping/domains/hotel/queries/studio.test.ts
@@ -69,6 +69,41 @@ describe("Studio operation query", () => {
});
});
+ it("normalizes clone workspace source, paging and inventory filters", async () => {
+ const load = vi.fn(async () => ({
+ kind: "studio" as const,
+ items: [],
+ total: 0,
+ partialDependencies: [] as const,
+ }));
+ await createStudioOperationQuery({ load }).run(
+ context([PERMS.ASSETS_IMPORT]),
+ {
+ routeId: "hotel.studio.clone",
+ clone: {
+ sourceId: " hotel-nl ",
+ search: " chair ",
+ filter: "present",
+ page: -4,
+ pageSize: 999,
+ },
+ },
+ );
+
+ expect(load).toHaveBeenCalledWith({
+ routeId: "hotel.studio.clone",
+ operationKind: "clone",
+ list: { search: "", pageSize: 25, offset: 0 },
+ clone: {
+ sourceId: "hotel-nl",
+ search: "chair",
+ filter: "present",
+ page: 1,
+ pageSize: 100,
+ },
+ });
+ });
+
it("loads the audit route across all operation kinds without inventing a command kind", async () => {
const load = vi.fn(async () => ({
kind: "studio" as const,
diff --git a/src/features/housekeeping/domains/hotel/queries/studio.ts b/src/features/housekeeping/domains/hotel/queries/studio.ts
index f5265aa2..c8c570de 100644
--- a/src/features/housekeeping/domains/hotel/queries/studio.ts
+++ b/src/features/housekeeping/domains/hotel/queries/studio.ts
@@ -224,6 +224,55 @@ export const studioOperationRepository: StudioOperationRepository = {
list: (input) => memoryStudioOperations.list(input),
};
+export type StudioCloneFilter = "all" | "missing" | "present";
+
+export interface StudioCloneSourceSummary {
+ readonly id: string;
+ readonly name: string;
+ readonly hotel: string;
+}
+
+export interface StudioCloneItemStatus {
+ readonly id: number;
+ readonly classname: string;
+ readonly name: string;
+ readonly type: "flooritem" | "wallitem";
+ readonly category: string;
+ readonly alreadyImported: boolean;
+ readonly nitroExists?: boolean;
+ readonly hasFurnidata?: boolean;
+ readonly hasCatalogEntry?: boolean;
+}
+
+export interface StudioCloneWorkspace {
+ readonly sources: readonly StudioCloneSourceSummary[];
+ readonly selectedSource: StudioCloneSourceSummary;
+ readonly stats: Readonly<{
+ total: number;
+ present: number;
+ clonable: number;
+ }>;
+ readonly items: readonly StudioCloneItemStatus[];
+ readonly meta: Readonly<{ page: number; pageSize: number; total: number }>;
+ readonly query: Readonly<{ search: string; filter: StudioCloneFilter }>;
+}
+
+export interface StudioCloneQueryInput {
+ readonly sourceId?: string;
+ readonly search?: string;
+ readonly filter?: StudioCloneFilter;
+ readonly page?: number;
+ readonly pageSize?: number;
+}
+
+export interface NormalizedStudioCloneQueryInput {
+ readonly sourceId: string;
+ readonly search: string;
+ readonly filter: StudioCloneFilter;
+ readonly page: number;
+ readonly pageSize: number;
+}
+
export interface StudioQueryInput {
readonly routeId: HotelStudioRouteId;
readonly list?: Readonly<{
@@ -231,12 +280,14 @@ export interface StudioQueryInput {
pageSize?: number;
offset?: number;
}>;
+ readonly clone?: StudioCloneQueryInput;
}
export interface NormalizedStudioQueryInput {
readonly routeId: HotelStudioRouteId;
readonly operationKind: StudioOperationKind | null;
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
+ readonly clone?: NormalizedStudioCloneQueryInput;
}
export interface StudioQueryData {
@@ -244,6 +295,7 @@ export interface StudioQueryData {
readonly items: readonly StudioOperationRecord[];
readonly total: number;
readonly partialDependencies: readonly string[];
+ readonly cloneWorkspace?: StudioCloneWorkspace;
}
export interface StudioQueryAdapters {
@@ -262,6 +314,11 @@ function boundedInteger(
}
function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput {
+ const cloneFilter = input.clone?.filter;
+ const normalizedCloneFilter: StudioCloneFilter =
+ cloneFilter === "missing" || cloneFilter === "present"
+ ? cloneFilter
+ : "all";
return {
routeId: input.routeId,
operationKind: hotelStudioOperationKind(input.routeId),
@@ -273,6 +330,23 @@ function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput {
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
},
+ ...(input.clone
+ ? {
+ clone: {
+ sourceId: String(input.clone.sourceId ?? "")
+ .normalize("NFC")
+ .trim()
+ .slice(0, 128),
+ search: String(input.clone.search ?? "")
+ .normalize("NFC")
+ .trim()
+ .slice(0, 128),
+ filter: normalizedCloneFilter,
+ page: boundedInteger(input.clone.page, 1, 1, 100_000),
+ pageSize: boundedInteger(input.clone.pageSize, 25, 1, 100),
+ },
+ }
+ : {}),
};
}
@@ -321,6 +395,74 @@ export function createStudioOperationQuery(
};
}
+async function loadStudioCloneWorkspace(
+ input: NormalizedStudioCloneQueryInput,
+): Promise<{
+ readonly workspace?: StudioCloneWorkspace;
+ readonly partialDependencies: readonly string[];
+}> {
+ try {
+ const [{ listSources }, { getCloneInventoryStatus }] = await Promise.all([
+ import("@/lib/services/clone-sources"),
+ import("@/lib/services/clone-inventory"),
+ ]);
+ const sources = await listSources();
+ const selected =
+ sources.find((source) => source.id === input.sourceId) ?? sources[0];
+ if (!selected) {
+ return { partialDependencies: ["external-sources"] };
+ }
+ const inventory = await getCloneInventoryStatus({
+ source: selected,
+ search: input.search,
+ page: input.page,
+ perPage: input.pageSize,
+ filter: input.filter,
+ });
+ return {
+ workspace: {
+ sources: sources.map(({ id, name, hotel }) => ({ id, name, hotel })),
+ selectedSource: {
+ id: selected.id,
+ name: selected.name,
+ hotel: selected.hotel,
+ },
+ stats: inventory.stats,
+ items: inventory.items.map(
+ (item): StudioCloneItemStatus => ({
+ id: item.id,
+ classname: item.classname,
+ name: item.name,
+ type: item.itemType === "i" ? "wallitem" : "flooritem",
+ category: item.category,
+ alreadyImported: item.present,
+ ...(item.nitroExists === undefined
+ ? {}
+ : { nitroExists: item.nitroExists }),
+ ...(item.hasFurnidata === undefined
+ ? {}
+ : { hasFurnidata: item.hasFurnidata }),
+ ...(item.hasCatalogEntry === undefined
+ ? {}
+ : { hasCatalogEntry: item.hasCatalogEntry }),
+ }),
+ ),
+ query: {
+ search: input.search,
+ filter: input.filter,
+ },
+ meta: {
+ page: inventory.meta.page,
+ pageSize: inventory.meta.perPage,
+ total: inventory.meta.total,
+ },
+ },
+ partialDependencies: inventory.partialDependencies,
+ };
+ } catch {
+ return { partialDependencies: ["external-sources"] };
+ }
+}
export const studioOperationQuery = createStudioOperationQuery({
async load(input) {
const result = await studioOperationRepository.list({
@@ -328,7 +470,21 @@ export const studioOperationQuery = createStudioOperationQuery({
...input.list,
});
const partialDependencies: string[] = [];
- if (input.operationKind === "clone" || input.operationKind === "sync") {
+ let cloneWorkspace: StudioCloneWorkspace | undefined;
+ if (input.operationKind === "clone") {
+ const cloneResult = await loadStudioCloneWorkspace(
+ input.clone ?? {
+ sourceId: "",
+ search: "",
+ filter: "all",
+ page: 1,
+ pageSize: 25,
+ },
+ );
+ cloneWorkspace = cloneResult.workspace;
+ partialDependencies.push(...cloneResult.partialDependencies);
+ }
+ if (input.operationKind === "sync") {
try {
const { listSources } = await import("@/lib/services/clone-sources");
if ((await listSources()).length === 0) {
@@ -343,6 +499,7 @@ export const studioOperationQuery = createStudioOperationQuery({
items: result.items,
total: result.total,
partialDependencies,
+ ...(cloneWorkspace ? { cloneWorkspace } : {}),
};
},
});
diff --git a/src/lib/services/clone-inventory.test.ts b/src/lib/services/clone-inventory.test.ts
new file mode 100644
index 00000000..e7d5a6f0
--- /dev/null
+++ b/src/lib/services/clone-inventory.test.ts
@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const dependencies = vi.hoisted(() => ({
+ getCloneList: vi.fn(),
+ getCloneStats: vi.fn(),
+ localItems: vi.fn(),
+ execute: vi.fn(),
+ readFurniData: vi.fn(),
+ getFurniAssetDirs: vi.fn(),
+ existsSync: vi.fn(),
+}));
+
+vi.mock("node:fs", () => ({ existsSync: dependencies.existsSync }));
+vi.mock("drizzle-orm", () => ({
+ inArray: vi.fn(() => "items-by-classname"),
+ sql: vi.fn(() => "catalog-item-ids"),
+}));
+vi.mock("@/lib/db", () => ({
+ ItemsBase: { id: "id", itemName: "itemName" },
+ db: {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({ where: dependencies.localItems })),
+ })),
+ execute: dependencies.execute,
+ },
+}));
+vi.mock("@/lib/services/clone-import", () => ({
+ getCloneList: dependencies.getCloneList,
+ getCloneStats: dependencies.getCloneStats,
+}));
+vi.mock("@/lib/services/furni-data", () => ({
+ readFurniData: dependencies.readFurniData,
+}));
+vi.mock("@/lib/services/furni-asset-dirs", () => ({
+ getFurniAssetDirs: dependencies.getFurniAssetDirs,
+}));
+
+import { getCloneInventoryStatus } from "./clone-inventory";
+
+describe("clone inventory status", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ dependencies.getCloneList.mockResolvedValue({
+ items: [
+ {
+ id: 900,
+ classname: "modern_chair",
+ name: "Modern chair",
+ description: "A modern chair",
+ itemType: "s",
+ category: "chair",
+ present: true,
+ },
+ ],
+ meta: { page: 1, perPage: 25, total: 1 },
+ });
+ dependencies.getCloneStats.mockResolvedValue({
+ total: 1,
+ present: 1,
+ clonable: 0,
+ });
+ dependencies.localItems.mockResolvedValue([
+ { id: 42, itemName: "modern_chair" },
+ ]);
+ dependencies.execute.mockResolvedValue([[{ item_ids: "42" }], undefined]);
+ dependencies.readFurniData.mockResolvedValue({
+ roomitemtypes: { furnitype: [{ classname: "modern_chair" }] },
+ });
+ dependencies.getFurniAssetDirs.mockResolvedValue({ nitroDir: "/nitros" });
+ dependencies.existsSync.mockReturnValue(true);
+ });
+
+ it("checks catalog presence with the allocated local ID, not the remote source ID", async () => {
+ const result = await getCloneInventoryStatus({
+ source: {
+ id: "hotel-nl",
+ name: "Hotel NL",
+ furnidataUrl: "https://hotel.test/furnidata.json",
+ nitroBaseUrl: "https://hotel.test/nitro",
+ iconBaseUrl: "https://hotel.test/icons",
+ hotel: "nl",
+ },
+ search: "",
+ page: 1,
+ perPage: 25,
+ filter: "all",
+ });
+
+ expect(result.items[0]).toMatchObject({
+ id: 900,
+ classname: "modern_chair",
+ hasCatalogEntry: true,
+ hasFurnidata: true,
+ nitroExists: true,
+ });
+ });
+});
diff --git a/src/lib/services/clone-inventory.ts b/src/lib/services/clone-inventory.ts
new file mode 100644
index 00000000..3c013038
--- /dev/null
+++ b/src/lib/services/clone-inventory.ts
@@ -0,0 +1,135 @@
+import { existsSync } from "node:fs";
+import path from "node:path";
+import { inArray, sql } from "drizzle-orm";
+import { db, ItemsBase } from "@/lib/db";
+import { getCloneList, getCloneStats } from "@/lib/services/clone-import";
+import type { CloneSource } from "@/lib/services/clone-sources";
+import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
+import { readFurniData } from "@/lib/services/furni-data";
+
+export interface CloneInventoryStatusItem {
+ readonly id: number;
+ readonly classname: string;
+ readonly name: string;
+ readonly itemType: "s" | "i";
+ readonly description: string;
+ readonly category: string;
+ readonly present: boolean;
+ readonly nitroExists?: boolean;
+ readonly hasFurnidata?: boolean;
+ readonly hasCatalogEntry?: boolean;
+}
+
+export interface CloneInventoryStatus {
+ readonly items: readonly CloneInventoryStatusItem[];
+ readonly stats: {
+ readonly total: number;
+ readonly present: number;
+ readonly clonable: number;
+ };
+ readonly meta: {
+ readonly page: number;
+ readonly perPage: number;
+ readonly total: number;
+ };
+ readonly partialDependencies: readonly ("database" | "filesystem")[];
+}
+
+export async function getCloneInventoryStatus(params: {
+ readonly source: CloneSource;
+ readonly search: string;
+ readonly page: number;
+ readonly perPage: number;
+ readonly filter: "all" | "missing" | "present";
+}): Promise {
+ const [listResult, stats] = await Promise.all([
+ getCloneList(params),
+ getCloneStats(params.source),
+ ]);
+ const partialDependencies = new Set<"database" | "filesystem">();
+ const importedNames = listResult.items
+ .filter((item) => item.present)
+ .map((item) => item.classname);
+ const localIdsByClassname = new Map();
+ const catalogItemIds = new Set();
+ if (importedNames.length > 0) {
+ try {
+ const localItems = await db
+ .select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
+ .from(ItemsBase)
+ .where(inArray(ItemsBase.itemName, importedNames));
+ for (const item of localItems) {
+ if (item.itemName) localIdsByClassname.set(item.itemName, item.id);
+ }
+ const catalogRows = await db.execute(
+ sql`SELECT item_ids FROM catalog_items`,
+ );
+ for (const row of (
+ catalogRows as unknown as [Array<{ item_ids: string | null }>, unknown]
+ )[0]) {
+ for (const rawId of String(row.item_ids ?? "").split(/[;,]/)) {
+ const id = Number(rawId.trim());
+ if (Number.isSafeInteger(id) && id > 0) catalogItemIds.add(id);
+ }
+ }
+ } catch {
+ partialDependencies.add("database");
+ }
+ }
+
+ let furnidataClassnames: Set | null = null;
+ try {
+ const furnidata = (await readFurniData()) as {
+ roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
+ wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
+ };
+ furnidataClassnames = new Set(
+ [
+ ...(furnidata.roomitemtypes?.furnitype ?? []),
+ ...(furnidata.wallitemtypes?.furnitype ?? []),
+ ]
+ .map((item) => item.classname)
+ .filter((classname): classname is string => Boolean(classname)),
+ );
+ } catch {
+ partialDependencies.add("filesystem");
+ }
+
+ let nitroExists: ((classname: string) => boolean) | null = null;
+ try {
+ const { nitroDir } = await getFurniAssetDirs();
+ nitroExists = (classname) => {
+ const base = classname.split("*", 1)[0];
+ return existsSync(path.join(nitroDir, `${base}.nitro`));
+ };
+ } catch {
+ partialDependencies.add("filesystem");
+ }
+
+ return {
+ stats,
+ meta: listResult.meta,
+ partialDependencies: [...partialDependencies],
+ items: listResult.items.map((item) => {
+ const localId = localIdsByClassname.get(item.classname);
+ return {
+ id: item.id,
+ classname: item.classname,
+ name: item.name,
+ description: item.description,
+ itemType: item.itemType,
+ category: String(item.category ?? ""),
+ present: item.present,
+ ...(item.present && nitroExists
+ ? { nitroExists: nitroExists(item.classname) }
+ : {}),
+ ...(item.present && furnidataClassnames
+ ? { hasFurnidata: furnidataClassnames.has(item.classname) }
+ : {}),
+ ...(item.present && localId !== undefined
+ ? { hasCatalogEntry: catalogItemIds.has(localId) }
+ : {}),
+ };
+ }),
+ };
+}