feat(housekeeping): adapt catalog studio workflow
CI / runtime-diagnostics (pull_request) Skipped
CI / check (pull_request) Failing after 11s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-09-01 19:04:48 +02:00
1 parent 52e00854a2
commit c7891b60c3
11 files changed
+1305 -85

No files matched your search

+5 -36
View File
@@ -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<string>();
const catalogIdSet = new Set<number>();
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,
@@ -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", () => {
@@ -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<Record<string, unknown>>,
execution: StudioOperationExecution,
): Promise<StudioOperationRunnerResult> {
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<typeof cloneImport.cloneSingleFurni>
>;
}
| { 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<string, unknown>;
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<unknown>) => {
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<Record<string, unknown>>,
@@ -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(
@@ -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(
<OperationProgress
event={{
operationId: "op-eta",
kind: "clone",
phase: "running",
completed: 2,
total: 4,
messageKey: "pages.housekeeping.studio.running",
correlationId: "corr-eta",
actorId: 42,
createdAt: "2026-09-01T10:00:00.000Z",
updatedAt: "2026-09-01T10:00:20.000Z",
}}
/>,
);
expect(markup).toContain("50%");
expect(markup).toContain("Estimated remaining: 20s");
});
it("distinguishes partial and failed terminal results", () => {
const partial = renderToStaticMarkup(
<OperationResult
@@ -1,11 +1,33 @@
import type { StudioOperationEvent } from "../queries/studio";
import type {
StudioOperationEvent,
StudioOperationRecord,
} from "../queries/studio";
export function OperationProgress({
event,
}: {
readonly event: StudioOperationEvent;
readonly event: StudioOperationEvent | StudioOperationRecord;
}) {
const determinate = event.total !== null && event.total > 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 (
<div
role="progressbar"
@@ -19,7 +41,16 @@ export function OperationProgress({
<span>
{event.completed}
{event.total === null ? " completed" : ` / ${event.total}`}
{percentage === null ? null : ` (${percentage}%)`}
</span>
{estimatedSeconds !== null && estimatedSeconds > 0 ? (
<span>
Estimated remaining:{" "}
{Math.floor(estimatedSeconds / 60) > 0
? `${Math.floor(estimatedSeconds / 60)}m ${estimatedSeconds % 60}s`
: `${estimatedSeconds}s`}
</span>
) : null}
</div>
);
}
@@ -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(
<StudioPage
context={context([PERMS.ASSETS_IMPORT])}
result={ok(
{
kind: "studio",
items: [
{
operationId: "op-failed",
kind: "clone",
phase: "partial",
completed: 2,
total: 3,
messageKey: "pages.housekeeping.studio.partial",
correlationId: "corr-failed",
actorId: 42,
createdAt: "2026-09-01T10:00:00.000Z",
updatedAt: "2026-09-01T10:01:00.000Z",
output: {
sourceId: "hotel-nl",
failedClassnames: ["broken_chair", "broken_lamp"],
},
},
],
total: 1,
partialDependencies: [],
cloneWorkspace: {
sources: [{ id: "hotel-nl", name: "Hotel NL", hotel: "nl" }],
selectedSource: { id: "hotel-nl", name: "Hotel NL", hotel: "nl" },
stats: { total: 5, present: 2, clonable: 3 },
query: { search: "chair", filter: "missing" },
items: [
{
id: 99,
classname: "modern_chair",
name: "Modern chair",
type: "flooritem",
category: "chairs",
alreadyImported: true,
nitroExists: false,
hasFurnidata: true,
hasCatalogEntry: false,
},
],
meta: { page: 1, pageSize: 25, total: 55 },
},
},
"clone-workspace",
)}
routeId="hotel.studio.clone"
/>,
);
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(
<StudioPage
context={context([PERMS.ASSETS_IMPORT])}
result={ok(
{
kind: "studio",
items: [],
total: 0,
partialDependencies: ["filesystem"],
cloneWorkspace: {
sources: [{ id: "hotel-nl", name: "Hotel NL", hotel: "nl" }],
selectedSource: {
id: "hotel-nl",
name: "Hotel NL",
hotel: "nl",
},
stats: { total: 1, present: 1, clonable: 0 },
query: { search: "", filter: "all" },
items: [
{
id: 900,
classname: "modern_chair",
name: "Modern chair",
type: "flooritem",
category: "chairs",
alreadyImported: true,
},
],
meta: { page: 1, pageSize: 25, total: 1 },
},
},
"clone-unknown",
)}
routeId="hotel.studio.clone"
/>,
);
expect(markup).not.toContain("Ready");
});
it("uses route-specific fields instead of a generic JSON payload", () => {
const markup = renderToStaticMarkup(
<StudioPage
context={context([PERMS.ASSETS_IMPORT])}
result={ok(
{ kind: "studio", items: [], total: 0, partialDependencies: [] },
"typed-furni",
)}
routeId="hotel.studio.furni"
/>,
);
expect(markup).toContain('name="classname"');
expect(markup).toContain('name="revision"');
expect(markup).toContain('name="updateExisting"');
expect(markup).not.toContain("Operation input (JSON)");
});
});
@@ -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<StudioOperationKind, "clone">,
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 <p>Clone inventory is temporarily unavailable.</p>;
}
const { selectedSource, stats } = workspace;
const lastPage = Math.max(
1,
Math.ceil(workspace.meta.total / workspace.meta.pageSize),
);
return (
<section className="space-y-4" aria-labelledby="studio-clone-workspace">
<header>
<h2 id="studio-clone-workspace">
Clone inventory — {selectedSource.name}
</h2>
<p>
{stats.present} imported · {stats.clonable} missing · {stats.total}{" "}
total
</p>
</header>
<nav aria-label="Clone source" className="flex flex-wrap gap-2">
{workspace.sources.map((source) => (
<a
key={source.id}
href={`?sourceId=${encodeURIComponent(source.id)}`}
aria-current={source.id === selectedSource.id ? "page" : undefined}
>
{source.name}
</a>
))}
</nav>
<form method="get" className="flex flex-wrap gap-2">
<input type="hidden" name="sourceId" value={selectedSource.id} />
<label>
Search inventory
<input
name="itemSearch"
type="search"
maxLength={128}
defaultValue={workspace.query.search}
/>
</label>
<label>
Inventory status
<select name="status" defaultValue={workspace.query.filter}>
<option value="all">All</option>
<option value="missing">Not imported</option>
<option value="present">Imported</option>
</select>
</label>
<button type="submit">Apply filters</button>
</form>
{stats.clonable > 0 ? (
<HotelCommandForm
commandId="hotel.studio.clone.run"
buttonLabel={`Clone all missing (${stats.clonable})`}
input={{ mode: "all-missing", sourceId: selectedSource.id }}
fields={CLONE_TRANSLATION_FIELDS}
requiresReason
/>
) : null}
<HotelCommandForm
commandId="hotel.studio.clone.run"
buttonLabel="Clone one item"
input={{ mode: "single", sourceId: selectedSource.id }}
fields={[
{
name: "classname",
label: "Classname",
type: "text",
required: true,
},
...CLONE_TRANSLATION_FIELDS,
]}
requiresReason
/>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th scope="col">Furni</th>
<th scope="col">Classname</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{workspace.items.map((item) => (
<tr key={item.classname}>
<td>{item.name}</td>
<td>{item.classname}</td>
<td>
{!item.alreadyImported ? <span>Not imported</span> : null}
{item.nitroExists === false ? (
<span>Missing Nitro</span>
) : null}
{item.hasFurnidata === false ? (
<span>Missing FurnitureData</span>
) : null}
{item.hasCatalogEntry === false ? (
<span>Missing catalog entry</span>
) : null}
{item.alreadyImported &&
(item.nitroExists === undefined ||
item.hasFurnidata === undefined ||
item.hasCatalogEntry === undefined) ? (
<span>Status unavailable</span>
) : null}
{item.alreadyImported &&
item.nitroExists === true &&
item.hasFurnidata === true &&
item.hasCatalogEntry === true ? (
<span>Ready</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
<nav aria-label="Clone inventory pages" className="flex gap-2">
{workspace.meta.page > 1 ? (
<a href={cloneInventoryHref(workspace, workspace.meta.page - 1)}>
Previous
</a>
) : null}
<span>
Page {workspace.meta.page} of {lastPage}
</span>
{workspace.meta.page < lastPage ? (
<a href={cloneInventoryHref(workspace, workspace.meta.page + 1)}>
Next
</a>
) : null}
</nav>
{operations.map((operation) => {
const retry = retryPayload(operation);
return retry ? (
<HotelCommandForm
key={`retry-${operation.operationId}`}
commandId="hotel.studio.clone.run"
buttonLabel={`Retry failed items (${retry.classnames.length})`}
input={{
mode: "retry",
sourceId: retry.sourceId,
classnames: retry.classnames,
}}
fields={CLONE_TRANSLATION_FIELDS}
requiresReason
/>
) : null;
})}
</section>
);
}
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(", ")}.
</p>
) : null}
{routeId && operationKind && canRun(context, routeId) ? (
{routeId && operationKind === "clone" && canRun(context, routeId) ? (
<CloneWorkspace
workspace={result.data.cloneWorkspace}
operations={result.data.items}
/>
) : null}
{routeId &&
operationKind &&
operationKind !== "clone" &&
canRun(context, routeId) ? (
<HotelCommandForm
commandId={`hotel.studio.${operationKind}.run`}
buttonLabel={`Start ${operationKind} operation`}
input={{}}
fields={[
{
name: "payload",
label: "Operation input (JSON)",
type: "json",
required: true,
maxLength: 100_000,
},
]}
fields={STUDIO_FIELDS[operationKind]}
requiresReason
/>
) : 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 (
<StudioPage context={input.context} result={result} routeId={routeId} />
@@ -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,
@@ -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 } : {}),
};
},
});
+97
View File
@@ -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,
});
});
});
+135
View File
@@ -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<CloneInventoryStatus> {
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<string, number>();
const catalogItemIds = new Set<number>();
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<string> | 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) }
: {}),
};
}),
};
}