feat(housekeeping): adapt catalog studio workflow
This commit is contained in:
1 parent
52e00854a2
commit
c7891b60c3
11 files changed
+1305
-85
No files matched your search
@@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user