feat(housekeeping): integrate studio operations
This commit is contained in:
1 parent
020c06f172
commit
2e95a106e0
36 files changed
+2290
-54
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { OperationProgress } from "./operation-progress";
|
||||||
|
import { OperationResult } from "./operation-result";
|
||||||
|
|
||||||
|
describe("Studio operation components", () => {
|
||||||
|
it("renders determinate progress with an accessible label", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<OperationProgress
|
||||||
|
event={{
|
||||||
|
operationId: "op-1",
|
||||||
|
kind: "furni",
|
||||||
|
phase: "running",
|
||||||
|
completed: 2,
|
||||||
|
total: 4,
|
||||||
|
messageKey: "pages.housekeeping.studio.running",
|
||||||
|
correlationId: "corr-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(markup).toContain('role="progressbar"');
|
||||||
|
expect(markup).toContain('aria-valuenow="2"');
|
||||||
|
expect(markup).toContain("2 / 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes partial and failed terminal results", () => {
|
||||||
|
const partial = renderToStaticMarkup(
|
||||||
|
<OperationResult
|
||||||
|
operation={{
|
||||||
|
operationId: "op-1",
|
||||||
|
kind: "sync",
|
||||||
|
phase: "partial",
|
||||||
|
completed: 8,
|
||||||
|
total: 10,
|
||||||
|
messageKey: "pages.housekeeping.studio.partial",
|
||||||
|
correlationId: "corr-1",
|
||||||
|
actorId: 42,
|
||||||
|
createdAt: "2026-08-30T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-30T10:01:00.000Z",
|
||||||
|
output: { succeeded: 8, failed: 2 },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(partial).toContain('data-operation-result="partial"');
|
||||||
|
expect(partial).toContain("corr-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { StudioOperationEvent } from "../queries/studio";
|
||||||
|
|
||||||
|
export function OperationProgress({
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
readonly event: StudioOperationEvent;
|
||||||
|
}) {
|
||||||
|
const determinate = event.total !== null && event.total > 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={`${event.kind} operation ${event.phase}`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuenow={determinate ? event.completed : undefined}
|
||||||
|
aria-valuemax={determinate ? (event.total ?? undefined) : undefined}
|
||||||
|
data-operation-phase={event.phase}
|
||||||
|
>
|
||||||
|
<strong>{event.kind}</strong>: {event.messageKey}
|
||||||
|
<span>
|
||||||
|
{event.completed}
|
||||||
|
{event.total === null ? " completed" : ` / ${event.total}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { StudioOperationRecord } from "../queries/studio";
|
||||||
|
|
||||||
|
export function OperationResult({
|
||||||
|
operation,
|
||||||
|
}: {
|
||||||
|
readonly operation: StudioOperationRecord;
|
||||||
|
}) {
|
||||||
|
if (operation.phase === "queued" || operation.phase === "running")
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
<article data-operation-result={operation.phase} role="status">
|
||||||
|
<h3>{operation.phase}</h3>
|
||||||
|
<p>{operation.messageKey}</p>
|
||||||
|
<p>Correlation: {operation.correlationId}</p>
|
||||||
|
{operation.output ? (
|
||||||
|
<pre>{JSON.stringify(operation.output, null, 2)}</pre>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||||
|
import { createHotelInboxSources } from "./inbox";
|
||||||
|
import { createHotelSearchProviders } from "./search";
|
||||||
|
import { createHotelWidgets } from "./widgets";
|
||||||
|
|
||||||
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
|
const permissions = new Set(granted);
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => permissions.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Hotel Studio providers", () => {
|
||||||
|
it("publishes searchable Studio operations with capability filtering", async () => {
|
||||||
|
const [provider] = createHotelSearchProviders({
|
||||||
|
studio: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: "op-1",
|
||||||
|
title: "Furniture import",
|
||||||
|
description: "running",
|
||||||
|
href: "/ase/hotel/studio/furni",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const result = await provider.search(context([PERMS.ASSETS_IMPORT]), {
|
||||||
|
term: " furni ",
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: [{ id: "op-1", domain: "hotel" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes an operational inbox source and mandatory active widget", async () => {
|
||||||
|
const operation = {
|
||||||
|
operationId: "op-1",
|
||||||
|
kind: "sync" as const,
|
||||||
|
phase: "failed" as const,
|
||||||
|
completed: 3,
|
||||||
|
total: 5,
|
||||||
|
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||||
|
correlationId: "corr-1",
|
||||||
|
actorId: 42,
|
||||||
|
createdAt: "2026-08-30T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-30T10:01:00.000Z",
|
||||||
|
};
|
||||||
|
const [source] = createHotelInboxSources({
|
||||||
|
operations: vi.fn(async () => [operation]),
|
||||||
|
});
|
||||||
|
const [widget] = createHotelWidgets({
|
||||||
|
activeOperations: vi.fn(async () => ({ running: 2, failed: 1 })),
|
||||||
|
});
|
||||||
|
expect(widget.kind).toBe("mandatory");
|
||||||
|
expect(
|
||||||
|
await source.getItems(
|
||||||
|
context([PERMS.ASSETS_IMPORT]),
|
||||||
|
new AbortController().signal,
|
||||||
|
),
|
||||||
|
).toMatchObject({ ok: true, data: { items: [{ state: "failed" }] } });
|
||||||
|
expect(
|
||||||
|
await widget.load(
|
||||||
|
context([PERMS.ASSETS_IMPORT]),
|
||||||
|
new AbortController().signal,
|
||||||
|
),
|
||||||
|
).toMatchObject({ ok: true, data: { running: 2, failed: 1 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,10 @@ import {
|
|||||||
anyCapability,
|
anyCapability,
|
||||||
type HousekeepingDomainManifest,
|
type HousekeepingDomainManifest,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
import { HOTEL_PRIMARY_ROUTES } from "./routes";
|
import { HOTEL_INBOX_SOURCES } from "./inbox";
|
||||||
|
import { HOTEL_ROUTES } from "./routes";
|
||||||
|
import { HOTEL_SEARCH_PROVIDERS } from "./search";
|
||||||
|
import { HOTEL_WIDGETS } from "./widgets";
|
||||||
|
|
||||||
export const hotelManifest = {
|
export const hotelManifest = {
|
||||||
id: "hotel",
|
id: "hotel",
|
||||||
@@ -21,8 +24,8 @@ export const hotelManifest = {
|
|||||||
PERMS.PAGES_VIEW,
|
PERMS.PAGES_VIEW,
|
||||||
PERMS.CATALOG_EDIT,
|
PERMS.CATALOG_EDIT,
|
||||||
),
|
),
|
||||||
routes: HOTEL_PRIMARY_ROUTES,
|
routes: HOTEL_ROUTES,
|
||||||
searchProviders: [],
|
searchProviders: HOTEL_SEARCH_PROVIDERS,
|
||||||
inboxSources: [],
|
inboxSources: HOTEL_INBOX_SOURCES,
|
||||||
widgets: [],
|
widgets: HOTEL_WIDGETS,
|
||||||
} satisfies HousekeepingDomainManifest;
|
} satisfies HousekeepingDomainManifest;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -4,11 +4,13 @@ import { renderHotelRadioPage } from "./pages/radio";
|
|||||||
import { renderHotelRoomDetailPage } from "./pages/room-detail";
|
import { renderHotelRoomDetailPage } from "./pages/room-detail";
|
||||||
import { renderHotelRoomFurniPage } from "./pages/room-furni";
|
import { renderHotelRoomFurniPage } from "./pages/room-furni";
|
||||||
import { renderHotelRoomsPage } from "./pages/rooms";
|
import { renderHotelRoomsPage } from "./pages/rooms";
|
||||||
import { HOTEL_PRIMARY_ROUTE_IDS, type HotelPrimaryRouteId } from "./routes";
|
import { renderHotelStudioPage } from "./pages/studio";
|
||||||
|
import { HOTEL_ROUTE_IDS, type HotelRouteId } from "./routes";
|
||||||
|
|
||||||
function rendererFor(
|
function rendererFor(
|
||||||
routeId: HotelPrimaryRouteId,
|
routeId: HotelRouteId,
|
||||||
): HousekeepingRouteHandler["render"] {
|
): HousekeepingRouteHandler["render"] {
|
||||||
|
if (routeId.startsWith("hotel.studio.")) return renderHotelStudioPage;
|
||||||
if (routeId === "hotel.rooms") return renderHotelRoomsPage;
|
if (routeId === "hotel.rooms") return renderHotelRoomsPage;
|
||||||
if (routeId === "hotel.room-detail") return renderHotelRoomDetailPage;
|
if (routeId === "hotel.room-detail") return renderHotelRoomDetailPage;
|
||||||
if (routeId === "hotel.room-furni") return renderHotelRoomFurniPage;
|
if (routeId === "hotel.room-furni") return renderHotelRoomFurniPage;
|
||||||
@@ -18,7 +20,7 @@ function rendererFor(
|
|||||||
|
|
||||||
export const HOTEL_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
export const HOTEL_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||||
Object.freeze(
|
Object.freeze(
|
||||||
HOTEL_PRIMARY_ROUTE_IDS.map((routeId) =>
|
HOTEL_ROUTE_IDS.map((routeId) =>
|
||||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
HOTEL_ASSET_CANONICAL_ROUTES,
|
HOTEL_ASSET_CANONICAL_ROUTES,
|
||||||
HOTEL_PRIMARY_ROUTE_IDS,
|
HOTEL_PRIMARY_ROUTE_IDS,
|
||||||
HOTEL_PRIMARY_ROUTES,
|
HOTEL_PRIMARY_ROUTES,
|
||||||
|
HOTEL_ROUTE_IDS,
|
||||||
|
HOTEL_ROUTES,
|
||||||
|
HOTEL_STUDIO_ROUTE_IDS,
|
||||||
|
HOTEL_STUDIO_ROUTES,
|
||||||
hotelRouteGroup,
|
hotelRouteGroup,
|
||||||
|
hotelStudioOperationKind,
|
||||||
} from "./routes";
|
} from "./routes";
|
||||||
|
|
||||||
const expected = [
|
const expected = [
|
||||||
@@ -74,11 +79,47 @@ describe("Hotel primary routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps manifest routes and handlers in exact equality", () => {
|
it("keeps manifest routes and handlers in exact equality", () => {
|
||||||
expect(hotelManifest.routes).toBe(HOTEL_PRIMARY_ROUTES);
|
expect(hotelManifest.routes).toBe(HOTEL_ROUTES);
|
||||||
expect(
|
expect(
|
||||||
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
||||||
handler.routeId.startsWith("hotel."),
|
handler.routeId.startsWith("hotel."),
|
||||||
).map((handler) => handler.routeId),
|
).map((handler) => handler.routeId),
|
||||||
).toEqual(HOTEL_PRIMARY_ROUTE_IDS);
|
).toEqual(HOTEL_ROUTE_IDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers every canonical Studio destination exactly once", () => {
|
||||||
|
const matrixPaths = [
|
||||||
|
...new Set(
|
||||||
|
hotelMigrationEntries
|
||||||
|
.map((entry) => entry.targetPath)
|
||||||
|
.filter(
|
||||||
|
(path): path is string =>
|
||||||
|
path?.startsWith("/ase/hotel/studio/") === true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
expect(HOTEL_STUDIO_ROUTES.map((route) => route.href).sort()).toEqual(
|
||||||
|
matrixPaths,
|
||||||
|
);
|
||||||
|
expect(new Set(HOTEL_STUDIO_ROUTE_IDS).size).toBe(
|
||||||
|
HOTEL_STUDIO_ROUTE_IDS.length,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
HOTEL_STUDIO_ROUTE_IDS.map((routeId) =>
|
||||||
|
hotelStudioOperationKind(routeId),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
null,
|
||||||
|
"badge",
|
||||||
|
"clone",
|
||||||
|
"clothing",
|
||||||
|
"effect",
|
||||||
|
"furni",
|
||||||
|
"maintenance",
|
||||||
|
"pet",
|
||||||
|
"repair-icons",
|
||||||
|
"sync",
|
||||||
|
"upload",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
fail,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingWidgetDefinition,
|
||||||
|
ok,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
import { studioOperationRepository } from "./queries/studio";
|
||||||
|
|
||||||
|
export const HOTEL_WIDGET_IDS = ["hotel.studio.active-operations"] as const;
|
||||||
|
|
||||||
|
type HotelWidgetLoader = (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<Readonly<Record<string, number>>>;
|
||||||
|
|
||||||
|
export interface HotelWidgetAdapters {
|
||||||
|
readonly activeOperations: HotelWidgetLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHotelWidgets(
|
||||||
|
adapters: HotelWidgetAdapters,
|
||||||
|
): readonly HousekeepingWidgetDefinition[] {
|
||||||
|
const capability = anyCapability(PERMS.ASSETS_IMPORT, PERMS.CATALOG_EDIT);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "hotel.studio.active-operations",
|
||||||
|
owner: "hotel",
|
||||||
|
kind: "mandatory",
|
||||||
|
capability,
|
||||||
|
async load(context, signal) {
|
||||||
|
const authorization = authorizeHousekeeping(context, capability);
|
||||||
|
if (!authorization.ok) return authorization;
|
||||||
|
try {
|
||||||
|
if (signal.aborted) throw new Error("aborted Hotel widget");
|
||||||
|
return ok(
|
||||||
|
await adapters.activeOperations(context, signal),
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return fail(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOTEL_WIDGETS = createHotelWidgets({
|
||||||
|
async activeOperations(_context, signal) {
|
||||||
|
if (signal.aborted) throw new Error("aborted Hotel operations widget");
|
||||||
|
const { items } = await studioOperationRepository.list({
|
||||||
|
pageSize: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
queued: items.filter((item) => item.phase === "queued").length,
|
||||||
|
running: items.filter((item) => item.phase === "running").length,
|
||||||
|
partial: items.filter((item) => item.phase === "partial").length,
|
||||||
|
failed: items.filter((item) => item.phase === "failed").length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import "server-only";
|
|||||||
import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands";
|
import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands";
|
||||||
import { ECONOMY_COMMANDS } from "../../domains/economy/commands/economy-commands";
|
import { ECONOMY_COMMANDS } from "../../domains/economy/commands/economy-commands";
|
||||||
import { HOTEL_COMMANDS } from "../../domains/hotel/commands/hotel-commands";
|
import { HOTEL_COMMANDS } from "../../domains/hotel/commands/hotel-commands";
|
||||||
|
import { STUDIO_COMMANDS } from "../../domains/hotel/commands/studio-commands";
|
||||||
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
||||||
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
||||||
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
||||||
@@ -46,6 +47,7 @@ const currentHousekeepingCommands = defineHousekeepingCommands(
|
|||||||
...CONTENT_COMMANDS,
|
...CONTENT_COMMANDS,
|
||||||
...ECONOMY_COMMANDS,
|
...ECONOMY_COMMANDS,
|
||||||
...HOTEL_COMMANDS,
|
...HOTEL_COMMANDS,
|
||||||
|
...STUDIO_COMMANDS,
|
||||||
...USER_COMMANDS,
|
...USER_COMMANDS,
|
||||||
...COMMUNITY_COMMANDS,
|
...COMMUNITY_COMMANDS,
|
||||||
...SUPPORT_COMMANDS,
|
...SUPPORT_COMMANDS,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
|
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
|
||||||
import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
|
import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
|
||||||
import { HOTEL_PRIMARY_ROUTE_IDS } from "../domains/hotel/routes";
|
import { HOTEL_ROUTE_IDS } from "../domains/hotel/routes";
|
||||||
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
||||||
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||||
@@ -23,6 +23,10 @@ const SERVER_CAPABILITY_CONTEXT =
|
|||||||
const PERMISSIONS_ADAPTER = "src/lib/permissions";
|
const PERMISSIONS_ADAPTER = "src/lib/permissions";
|
||||||
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
|
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
|
||||||
const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/commands/studio-commands.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/hotel/routes.ts",
|
"src/features/housekeeping/domains/hotel/routes.ts",
|
||||||
new Set(["src/features/housekeeping/domains/hotel/queries/assets"]),
|
new Set(["src/features/housekeeping/domains/hotel/queries/assets"]),
|
||||||
@@ -713,7 +717,32 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/hotel/manifest.ts",
|
"src/features/housekeeping/domains/hotel/manifest.ts",
|
||||||
new Set(["src/features/housekeeping/domains/hotel/routes"]),
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/hotel/inbox",
|
||||||
|
"src/features/housekeeping/domains/hotel/routes",
|
||||||
|
"src/features/housekeeping/domains/hotel/search",
|
||||||
|
"src/features/housekeeping/domains/hotel/widgets",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/components/operation-progress.tsx",
|
||||||
|
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/components/operation-result.tsx",
|
||||||
|
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/inbox.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/search.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/widgets.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/hotel/pages/hotel-command-form.tsx",
|
"src/features/housekeeping/domains/hotel/pages/hotel-command-form.tsx",
|
||||||
@@ -765,6 +794,16 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/pages/studio.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/hotel/components/operation-progress",
|
||||||
|
"src/features/housekeeping/domains/hotel/components/operation-result",
|
||||||
|
"src/features/housekeeping/domains/hotel/pages/hotel-command-form",
|
||||||
|
"src/features/housekeeping/domains/hotel/queries/studio",
|
||||||
|
"src/features/housekeeping/domains/hotel/routes",
|
||||||
|
]),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/hotel/queries/hotel-production.ts",
|
"src/features/housekeeping/domains/hotel/queries/hotel-production.ts",
|
||||||
new Set([
|
new Set([
|
||||||
@@ -790,6 +829,10 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/hotel/routes",
|
"src/features/housekeeping/domains/hotel/routes",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/hotel/queries/studio.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/hotel/routes"]),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/hotel/route-handlers.ts",
|
"src/features/housekeeping/domains/hotel/route-handlers.ts",
|
||||||
new Set([
|
new Set([
|
||||||
@@ -798,6 +841,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/hotel/pages/room-detail",
|
"src/features/housekeeping/domains/hotel/pages/room-detail",
|
||||||
"src/features/housekeeping/domains/hotel/pages/room-furni",
|
"src/features/housekeeping/domains/hotel/pages/room-furni",
|
||||||
"src/features/housekeeping/domains/hotel/pages/rooms",
|
"src/features/housekeeping/domains/hotel/pages/rooms",
|
||||||
|
"src/features/housekeeping/domains/hotel/pages/studio",
|
||||||
"src/features/housekeeping/domains/hotel/routes",
|
"src/features/housekeeping/domains/hotel/routes",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
@@ -831,6 +875,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/content/commands/content-commands",
|
"src/features/housekeeping/domains/content/commands/content-commands",
|
||||||
"src/features/housekeeping/domains/economy/commands/economy-commands",
|
"src/features/housekeeping/domains/economy/commands/economy-commands",
|
||||||
"src/features/housekeeping/domains/hotel/commands/hotel-commands",
|
"src/features/housekeeping/domains/hotel/commands/hotel-commands",
|
||||||
|
"src/features/housekeeping/domains/hotel/commands/studio-commands",
|
||||||
"src/features/housekeeping/domains/people/commands/community-commands",
|
"src/features/housekeeping/domains/people/commands/community-commands",
|
||||||
"src/features/housekeeping/domains/people/commands/moderation-commands",
|
"src/features/housekeeping/domains/people/commands/moderation-commands",
|
||||||
"src/features/housekeeping/domains/people/commands/support-commands",
|
"src/features/housekeeping/domains/people/commands/support-commands",
|
||||||
@@ -1484,7 +1529,7 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
registry.domains
|
registry.domains
|
||||||
.find((domain) => domain.id === "hotel")
|
.find((domain) => domain.id === "hotel")
|
||||||
?.routes.map((route) => route.id),
|
?.routes.map((route) => route.id),
|
||||||
).toEqual(HOTEL_PRIMARY_ROUTE_IDS);
|
).toEqual(HOTEL_ROUTE_IDS);
|
||||||
expect(
|
expect(
|
||||||
registry.domains
|
registry.domains
|
||||||
.find((domain) => domain.id === "system")
|
.find((domain) => domain.id === "system")
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ const routeMocks = vi.hoisted(() => {
|
|||||||
"routes.hotel.radio.api-keys": "HK::hotel-radio-api-keys",
|
"routes.hotel.radio.api-keys": "HK::hotel-radio-api-keys",
|
||||||
"routes.hotel.radio.banners": "HK::hotel-radio-banners",
|
"routes.hotel.radio.banners": "HK::hotel-radio-banners",
|
||||||
"routes.hotel.radio.embed": "HK::hotel-radio-embed",
|
"routes.hotel.radio.embed": "HK::hotel-radio-embed",
|
||||||
|
"routes.hotel.studio.audit": "HK::hotel-studio-audit",
|
||||||
|
"routes.hotel.studio.badges": "HK::hotel-studio-badges",
|
||||||
|
"routes.hotel.studio.clone": "HK::hotel-studio-clone",
|
||||||
|
"routes.hotel.studio.clothing": "HK::hotel-studio-clothing",
|
||||||
|
"routes.hotel.studio.effects": "HK::hotel-studio-effects",
|
||||||
|
"routes.hotel.studio.furni": "HK::hotel-studio-furni",
|
||||||
|
"routes.hotel.studio.maintenance": "HK::hotel-studio-maintenance",
|
||||||
|
"routes.hotel.studio.pets": "HK::hotel-studio-pets",
|
||||||
|
"routes.hotel.studio.repair-icons": "HK::hotel-studio-repair-icons",
|
||||||
|
"routes.hotel.studio.sync": "HK::hotel-studio-sync",
|
||||||
|
"routes.hotel.studio.upload": "HK::hotel-studio-upload",
|
||||||
"states.empty.title": "Localized empty title",
|
"states.empty.title": "Localized empty title",
|
||||||
"states.empty.description": "Localized empty description",
|
"states.empty.description": "Localized empty description",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { ECONOMY_INBOX_SOURCES } from "../domains/economy/inbox";
|
|||||||
import { ECONOMY_ROUTES } from "../domains/economy/routes";
|
import { ECONOMY_ROUTES } from "../domains/economy/routes";
|
||||||
import { ECONOMY_SEARCH_PROVIDERS } from "../domains/economy/search";
|
import { ECONOMY_SEARCH_PROVIDERS } from "../domains/economy/search";
|
||||||
import { ECONOMY_WIDGETS } from "../domains/economy/widgets";
|
import { ECONOMY_WIDGETS } from "../domains/economy/widgets";
|
||||||
import { HOTEL_PRIMARY_ROUTES } from "../domains/hotel/routes";
|
import { HOTEL_INBOX_SOURCES } from "../domains/hotel/inbox";
|
||||||
|
import { HOTEL_ROUTES } from "../domains/hotel/routes";
|
||||||
|
import { HOTEL_SEARCH_PROVIDERS } from "../domains/hotel/search";
|
||||||
|
import { HOTEL_WIDGETS } from "../domains/hotel/widgets";
|
||||||
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
||||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||||
@@ -375,7 +378,7 @@ describe("housekeeping registry", () => {
|
|||||||
: expected.id === "economy"
|
: expected.id === "economy"
|
||||||
? ECONOMY_ROUTES
|
? ECONOMY_ROUTES
|
||||||
: expected.id === "hotel"
|
: expected.id === "hotel"
|
||||||
? HOTEL_PRIMARY_ROUTES
|
? HOTEL_ROUTES
|
||||||
: expected.id === "system"
|
: expected.id === "system"
|
||||||
? SYSTEM_ROUTES
|
? SYSTEM_ROUTES
|
||||||
: [],
|
: [],
|
||||||
@@ -387,6 +390,8 @@ describe("housekeeping registry", () => {
|
|||||||
? CONTENT_SEARCH_PROVIDERS
|
? CONTENT_SEARCH_PROVIDERS
|
||||||
: expected.id === "economy"
|
: expected.id === "economy"
|
||||||
? ECONOMY_SEARCH_PROVIDERS
|
? ECONOMY_SEARCH_PROVIDERS
|
||||||
|
: expected.id === "hotel"
|
||||||
|
? HOTEL_SEARCH_PROVIDERS
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.inboxSources).toEqual(
|
expect(actual.inboxSources).toEqual(
|
||||||
@@ -396,6 +401,8 @@ describe("housekeeping registry", () => {
|
|||||||
? CONTENT_INBOX_SOURCES
|
? CONTENT_INBOX_SOURCES
|
||||||
: expected.id === "economy"
|
: expected.id === "economy"
|
||||||
? ECONOMY_INBOX_SOURCES
|
? ECONOMY_INBOX_SOURCES
|
||||||
|
: expected.id === "hotel"
|
||||||
|
? HOTEL_INBOX_SOURCES
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.widgets).toEqual(
|
expect(actual.widgets).toEqual(
|
||||||
@@ -405,6 +412,8 @@ describe("housekeeping registry", () => {
|
|||||||
? CONTENT_WIDGETS
|
? CONTENT_WIDGETS
|
||||||
: expected.id === "economy"
|
: expected.id === "economy"
|
||||||
? ECONOMY_WIDGETS
|
? ECONOMY_WIDGETS
|
||||||
|
: expected.id === "hotel"
|
||||||
|
? HOTEL_WIDGETS
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
|
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
|
||||||
import { ECONOMY_ROUTE_IDS } from "./domains/economy/routes";
|
import { ECONOMY_ROUTE_IDS } from "./domains/economy/routes";
|
||||||
import { HOTEL_PRIMARY_ROUTE_IDS } from "./domains/hotel/routes";
|
import { HOTEL_ROUTE_IDS } from "./domains/hotel/routes";
|
||||||
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
||||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||||
@@ -24,7 +24,7 @@ describe("housekeeping route handlers", () => {
|
|||||||
...PEOPLE_ROUTE_IDS,
|
...PEOPLE_ROUTE_IDS,
|
||||||
...CONTENT_ROUTE_IDS,
|
...CONTENT_ROUTE_IDS,
|
||||||
...ECONOMY_ROUTE_IDS,
|
...ECONOMY_ROUTE_IDS,
|
||||||
...HOTEL_PRIMARY_ROUTE_IDS,
|
...HOTEL_ROUTE_IDS,
|
||||||
...SYSTEM_ROUTE_IDS,
|
...SYSTEM_ROUTE_IDS,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,13 +134,16 @@ async function fetchWithFallback(url: string): Promise<string> {
|
|||||||
export async function fetchSourceFurnidata(
|
export async function fetchSourceFurnidata(
|
||||||
url: string,
|
url: string,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<SourceFurni[]> {
|
): Promise<SourceFurni[]> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const hit = cache.get(url);
|
const hit = cache.get(url);
|
||||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||||
let body: string;
|
let body: string;
|
||||||
try {
|
try {
|
||||||
|
const timeout = AbortSignal.timeout(30000);
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||||
headers: browserHeaders(),
|
headers: browserHeaders(),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -159,7 +162,8 @@ export async function fetchSourceFurnidata(
|
|||||||
} else {
|
} else {
|
||||||
body = await fetchWithFallback(url);
|
body = await fetchWithFallback(url);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
body = await fetchWithFallback(url);
|
body = await fetchWithFallback(url);
|
||||||
}
|
}
|
||||||
if (body.trimStart().startsWith("<")) {
|
if (body.trimStart().startsWith("<")) {
|
||||||
@@ -243,11 +247,13 @@ export async function cloneSingleFurni(params: {
|
|||||||
source: CloneSource;
|
source: CloneSource;
|
||||||
entry: SourceFurni;
|
entry: SourceFurni;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
/** Skip the per-item FurnitureData.json write and return the entry instead,
|
/** Skip the per-item FurnitureData.json write and return the entry instead,
|
||||||
* so batch callers can append everything in one lock + write cycle. */
|
* so batch callers can append everything in one lock + write cycle. */
|
||||||
deferFurniData?: boolean;
|
deferFurniData?: boolean;
|
||||||
}): Promise<CloneResult> {
|
}): Promise<CloneResult> {
|
||||||
const { source, entry, onProgress, deferFurniData } = params;
|
const { source, entry, onProgress, signal, deferFurniData } = params;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const { classname, itemType } = entry;
|
const { classname, itemType } = entry;
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
@@ -287,6 +293,7 @@ export async function cloneSingleFurni(params: {
|
|||||||
|
|
||||||
// Download .nitro + icon directly from the source hotel.
|
// Download .nitro + icon directly from the source hotel.
|
||||||
onProgress?.("downloading");
|
onProgress?.("downloading");
|
||||||
|
signal?.throwIfAborted();
|
||||||
const nitroPath = getRuntimePath(
|
const nitroPath = getRuntimePath(
|
||||||
/*turbopackIgnore: true*/ nitroDir,
|
/*turbopackIgnore: true*/ nitroDir,
|
||||||
`${classname}.nitro`,
|
`${classname}.nitro`,
|
||||||
@@ -300,6 +307,7 @@ export async function cloneSingleFurni(params: {
|
|||||||
nitroPath,
|
nitroPath,
|
||||||
{
|
{
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
|
signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!dl.ok) {
|
if (!dl.ok) {
|
||||||
@@ -334,6 +342,7 @@ export async function cloneSingleFurni(params: {
|
|||||||
{
|
{
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
validate: "png",
|
validate: "png",
|
||||||
|
signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!iconDl.ok) {
|
if (!iconDl.ok) {
|
||||||
@@ -366,6 +375,7 @@ export async function cloneSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onProgress?.("writing_db");
|
onProgress?.("writing_db");
|
||||||
|
signal?.throwIfAborted();
|
||||||
const stackHeight =
|
const stackHeight =
|
||||||
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
||||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||||
@@ -422,6 +432,7 @@ export async function cloneSingleFurni(params: {
|
|||||||
// AND our offerid. The source's offerid points at the source hotel's sprite
|
// AND our offerid. The source's offerid points at the source hotel's sprite
|
||||||
// ids, which would silently break the emulator's catalog offer lookup.
|
// ids, which would silently break the emulator's catalog offer lookup.
|
||||||
onProgress?.("writing_furnidata");
|
onProgress?.("writing_furnidata");
|
||||||
|
signal?.throwIfAborted();
|
||||||
const furniDataEntry = {
|
const furniDataEntry = {
|
||||||
...entry,
|
...entry,
|
||||||
id: newId,
|
id: newId,
|
||||||
|
|||||||
@@ -89,8 +89,10 @@ export async function importClothingSet(params: {
|
|||||||
setType: string;
|
setType: string;
|
||||||
setId: number;
|
setId: number;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<ImportClothingSetResult> {
|
}): Promise<ImportClothingSetResult> {
|
||||||
const { setType, setId, onProgress } = params;
|
const { setType, setId, onProgress, signal } = params;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
const [officialFd, officialLibs] = await Promise.all([
|
const [officialFd, officialLibs] = await Promise.all([
|
||||||
@@ -136,12 +138,17 @@ export async function importClothingSet(params: {
|
|||||||
let hardFail = false;
|
let hardFail = false;
|
||||||
|
|
||||||
for (const libId of requiredLibIds) {
|
for (const libId of requiredLibIds) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const officialLib = officialLibs.find((l) => l.id === libId);
|
const officialLib = officialLibs.find((l) => l.id === libId);
|
||||||
if (!officialLib) continue;
|
if (!officialLib) continue;
|
||||||
|
|
||||||
if (!localLibIds.has(libId)) {
|
if (!localLibIds.has(libId)) {
|
||||||
onProgress?.(`importing library ${libId}`);
|
onProgress?.(`importing library ${libId}`);
|
||||||
const result = await importSingleFigure({ lib: libId, onProgress });
|
const result = await importSingleFigure({
|
||||||
|
lib: libId,
|
||||||
|
onProgress,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
hardFail = true;
|
hardFail = true;
|
||||||
warnings.push(
|
warnings.push(
|
||||||
@@ -169,6 +176,7 @@ export async function importClothingSet(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mappedSet = mapOfficialSet(set);
|
const mappedSet = mapOfficialSet(set);
|
||||||
|
signal?.throwIfAborted();
|
||||||
await mergeSet(setType, st.paletteId, mappedSet as never);
|
await mergeSet(setType, st.paletteId, mappedSet as never);
|
||||||
|
|
||||||
if (palette) {
|
if (palette) {
|
||||||
|
|||||||
@@ -76,8 +76,10 @@ export async function importSingleEffect(params: {
|
|||||||
type: string;
|
type: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<ImportEffectResult> {
|
}): Promise<ImportEffectResult> {
|
||||||
const { id, lib, type, revision, onProgress } = params;
|
const { id, lib, type, revision, onProgress, signal } = params;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
await ensureEffectDirs();
|
await ensureEffectDirs();
|
||||||
|
|
||||||
@@ -100,7 +102,10 @@ export async function importSingleEffect(params: {
|
|||||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
const dl = await downloadFile(swfUrl, swfPath, {
|
||||||
|
validate: "swf",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (!dl.ok)
|
if (!dl.ok)
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -111,6 +116,7 @@ export async function importSingleEffect(params: {
|
|||||||
|
|
||||||
// Convert SWF → Nitro
|
// Convert SWF → Nitro
|
||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
|
signal?.throwIfAborted();
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||||
const result = convertSwfToNitro(swfBuffer, lib);
|
const result = convertSwfToNitro(swfBuffer, lib);
|
||||||
@@ -127,6 +133,7 @@ export async function importSingleEffect(params: {
|
|||||||
|
|
||||||
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
|
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
|
||||||
onProgress?.("writing_map");
|
onProgress?.("writing_map");
|
||||||
|
signal?.throwIfAborted();
|
||||||
try {
|
try {
|
||||||
const entry: EffectMapEntry = { id, lib, type, revision };
|
const entry: EffectMapEntry = { id, lib, type, revision };
|
||||||
await mergeEffect(entry);
|
await mergeEffect(entry);
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ async function nitroPathFor(lib: string): Promise<string> {
|
|||||||
export async function importSingleFigure(params: {
|
export async function importSingleFigure(params: {
|
||||||
lib: string;
|
lib: string;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<ImportFigureResult> {
|
}): Promise<ImportFigureResult> {
|
||||||
const { lib, onProgress } = params;
|
const { lib, onProgress, signal } = params;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
await ensureFigureDirs();
|
await ensureFigureDirs();
|
||||||
|
|
||||||
@@ -99,7 +101,10 @@ export async function importSingleFigure(params: {
|
|||||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
const dl = await downloadFile(swfUrl, swfPath, {
|
||||||
|
validate: "swf",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (!dl.ok)
|
if (!dl.ok)
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -109,6 +114,7 @@ export async function importSingleFigure(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
|
signal?.throwIfAborted();
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||||
const result = convertSwfToNitro(swfBuffer, lib);
|
const result = convertSwfToNitro(swfBuffer, lib);
|
||||||
|
|||||||
@@ -403,10 +403,13 @@ async function syncDirMissingFiles(
|
|||||||
* so all imported furniture stays renderable in-game even when a mirror
|
* so all imported furniture stays renderable in-game even when a mirror
|
||||||
* write was skipped.
|
* write was skipped.
|
||||||
*/
|
*/
|
||||||
export async function syncAssetsToGamedataBundle(): Promise<{
|
export async function syncAssetsToGamedataBundle(
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{
|
||||||
copiedNitros: string[];
|
copiedNitros: string[];
|
||||||
copiedIcons: string[];
|
copiedIcons: string[];
|
||||||
}> {
|
}> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (process.platform === "win32")
|
if (process.platform === "win32")
|
||||||
return { copiedNitros: [], copiedIcons: [] };
|
return { copiedNitros: [], copiedIcons: [] };
|
||||||
|
|
||||||
@@ -423,6 +426,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
|||||||
"bundled/furniture",
|
"bundled/furniture",
|
||||||
);
|
);
|
||||||
if (nitroDir !== nitroTarget) {
|
if (nitroDir !== nitroTarget) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||||
entry.endsWith(".nitro"),
|
entry.endsWith(".nitro"),
|
||||||
);
|
);
|
||||||
@@ -434,6 +438,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
|||||||
"icons",
|
"icons",
|
||||||
);
|
);
|
||||||
if (iconDir !== iconTarget) {
|
if (iconDir !== iconTarget) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
||||||
entry.endsWith("_icon.png"),
|
entry.endsWith("_icon.png"),
|
||||||
);
|
);
|
||||||
@@ -508,6 +513,7 @@ export async function importSingleFurni(params: {
|
|||||||
skipFurniDataWrite?: boolean;
|
skipFurniDataWrite?: boolean;
|
||||||
updateExisting?: boolean;
|
updateExisting?: boolean;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||||
sourceSwfBaseUrl?: string;
|
sourceSwfBaseUrl?: string;
|
||||||
/** Per-source nitro bundle + icon base URL. */
|
/** Per-source nitro bundle + icon base URL. */
|
||||||
@@ -525,10 +531,12 @@ export async function importSingleFurni(params: {
|
|||||||
skipFurniDataWrite,
|
skipFurniDataWrite,
|
||||||
updateExisting,
|
updateExisting,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
signal,
|
||||||
sourceSwfBaseUrl,
|
sourceSwfBaseUrl,
|
||||||
nitroBaseUrl: sourceNitroBaseUrl,
|
nitroBaseUrl: sourceNitroBaseUrl,
|
||||||
iconBaseUrl: sourceIconBaseUrl,
|
iconBaseUrl: sourceIconBaseUrl,
|
||||||
} = params;
|
} = params;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// Check if already exists by classname OR by spriteId (primary key collision).
|
// Check if already exists by classname OR by spriteId (primary key collision).
|
||||||
@@ -604,6 +612,7 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// ── Insert into DB with enriched data ──────────────────────────────
|
// ── Insert into DB with enriched data ──────────────────────────────
|
||||||
onProgress?.("writing_db");
|
onProgress?.("writing_db");
|
||||||
|
signal?.throwIfAborted();
|
||||||
try {
|
try {
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
|
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
|
||||||
@@ -660,6 +669,7 @@ export async function importSingleFurni(params: {
|
|||||||
// fall back to base-classname icon (Nitro reuses base for all colors
|
// fall back to base-classname icon (Nitro reuses base for all colors
|
||||||
// when no color-specific icon exists).
|
// when no color-specific icon exists).
|
||||||
onProgress?.("downloading");
|
onProgress?.("downloading");
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
const iconColorAware = classname.replace(/\*/g, "_");
|
const iconColorAware = classname.replace(/\*/g, "_");
|
||||||
|
|
||||||
@@ -701,8 +711,8 @@ export async function importSingleFurni(params: {
|
|||||||
// Download icon + SWF concurrently (independent assets). Each tries its
|
// Download icon + SWF concurrently (independent assets). Each tries its
|
||||||
// candidate URLs in order until one succeeds.
|
// candidate URLs in order until one succeeds.
|
||||||
const [iconOkResult, swfOk] = await Promise.all([
|
const [iconOkResult, swfOk] = await Promise.all([
|
||||||
tryDownloadCandidates(iconUrls, iconPath, "png"),
|
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
|
||||||
tryDownloadCandidates(swfUrls, swfPath, "swf"),
|
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
|
||||||
]);
|
]);
|
||||||
let iconOk = iconOkResult;
|
let iconOk = iconOkResult;
|
||||||
|
|
||||||
@@ -715,7 +725,10 @@ export async function importSingleFurni(params: {
|
|||||||
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
||||||
) {
|
) {
|
||||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||||
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" });
|
const dl = await downloadFile(nitroUrl, nitroPath, {
|
||||||
|
validate: "png",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (dl.ok) nitroDownloadOk = true;
|
if (dl.ok) nitroDownloadOk = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,6 +757,7 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
|
signal?.throwIfAborted();
|
||||||
let conversionResult: ConversionResult | null = null;
|
let conversionResult: ConversionResult | null = null;
|
||||||
|
|
||||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||||
|
|||||||
@@ -635,14 +635,21 @@ export interface FixAllResult {
|
|||||||
|
|
||||||
/** Run every repair in a sensible order, then return before/after health. */
|
/** Run every repair in a sensible order, then return before/after health. */
|
||||||
export async function fixEverything(
|
export async function fixEverything(
|
||||||
opts: { dedupePages?: boolean } = {},
|
opts: { dedupePages?: boolean; signal?: AbortSignal } = {},
|
||||||
): Promise<FixAllResult> {
|
): Promise<FixAllResult> {
|
||||||
|
opts.signal?.throwIfAborted();
|
||||||
const healthBefore = await getFurniHealth();
|
const healthBefore = await getFurniHealth();
|
||||||
|
opts.signal?.throwIfAborted();
|
||||||
const sprite = await fixSpriteIds();
|
const sprite = await fixSpriteIds();
|
||||||
|
opts.signal?.throwIfAborted();
|
||||||
const offers = await fixCatalogOffers();
|
const offers = await fixCatalogOffers();
|
||||||
|
opts.signal?.throwIfAborted();
|
||||||
const reconcile = await reconcileIds();
|
const reconcile = await reconcileIds();
|
||||||
|
opts.signal?.throwIfAborted();
|
||||||
const dedup = await removeDuplicates(opts.dedupePages ?? true);
|
const dedup = await removeDuplicates(opts.dedupePages ?? true);
|
||||||
|
opts.signal?.throwIfAborted();
|
||||||
const align = await forceItemsBaseIdsToFurnidata(true);
|
const align = await forceItemsBaseIdsToFurnidata(true);
|
||||||
|
opts.signal?.throwIfAborted();
|
||||||
const healthAfter = await getFurniHealth();
|
const healthAfter = await getFurniHealth();
|
||||||
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
|
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,9 @@ export async function writeBadgeToExternalTexts(
|
|||||||
code: string,
|
code: string,
|
||||||
name: string,
|
name: string,
|
||||||
description: string,
|
description: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<ImportBadgeResult> {
|
): Promise<ImportBadgeResult> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const gamedataRoot = await getGamedataRoot();
|
const gamedataRoot = await getGamedataRoot();
|
||||||
if (!gamedataRoot) {
|
if (!gamedataRoot) {
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +37,7 @@ export async function writeBadgeToExternalTexts(
|
|||||||
const raw = await fs.readFile(extPath, "utf-8");
|
const raw = await fs.readFile(extPath, "utf-8");
|
||||||
texts = JSON.parse(raw) as Record<string, string>;
|
texts = JSON.parse(raw) as Record<string, string>;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (signal?.aborted) throw err;
|
||||||
// File not present yet (fresh deployment) — start with an empty object.
|
// File not present yet (fresh deployment) — start with an empty object.
|
||||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -49,6 +52,7 @@ export async function writeBadgeToExternalTexts(
|
|||||||
}
|
}
|
||||||
texts = {};
|
texts = {};
|
||||||
}
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
if (texts[nameKey] !== undefined) {
|
if (texts[nameKey] !== undefined) {
|
||||||
return {
|
return {
|
||||||
@@ -63,9 +67,11 @@ export async function writeBadgeToExternalTexts(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
||||||
|
signal?.throwIfAborted();
|
||||||
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
|
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (signal?.aborted) throw err;
|
||||||
console.warn(
|
console.warn(
|
||||||
"[import-badges] Failed to update ExternalTexts.json:",
|
"[import-badges] Failed to update ExternalTexts.json:",
|
||||||
(err as Error).message,
|
(err as Error).message,
|
||||||
@@ -83,7 +89,9 @@ export async function upsertWebsiteBadge(
|
|||||||
code: string,
|
code: string,
|
||||||
name: string,
|
name: string,
|
||||||
description: string,
|
description: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await db
|
await db
|
||||||
.insert(WebsiteBadges)
|
.insert(WebsiteBadges)
|
||||||
@@ -107,21 +115,28 @@ export async function upsertWebsiteBadge(
|
|||||||
* Single import path: ExternalTexts.json + WebsiteBadges.
|
* Single import path: ExternalTexts.json + WebsiteBadges.
|
||||||
* Callers should gate with ASSETS_IMPORT.
|
* Callers should gate with ASSETS_IMPORT.
|
||||||
*/
|
*/
|
||||||
export async function importBadgeSynced(input: {
|
export async function importBadgeSynced(
|
||||||
|
input: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
}): Promise<ImportBadgeResult> {
|
},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ImportBadgeResult> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const texts = await writeBadgeToExternalTexts(
|
const texts = await writeBadgeToExternalTexts(
|
||||||
input.code,
|
input.code,
|
||||||
input.name,
|
input.name,
|
||||||
input.description,
|
input.description,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
if (!texts.ok) return texts;
|
if (!texts.ok) return texts;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await upsertWebsiteBadge(input.code, input.name, input.description);
|
signal?.throwIfAborted();
|
||||||
} catch {
|
await upsertWebsiteBadge(input.code, input.name, input.description, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
return { ok: false, error: "Failed to import badge", status: 500 };
|
return { ok: false, error: "Failed to import badge", status: 500 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ export async function tryDownloadCandidates(
|
|||||||
urls: string[],
|
urls: string[],
|
||||||
destPath: string,
|
destPath: string,
|
||||||
kind: "swf" | "png",
|
kind: "swf" | "png",
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
const res = await downloadFile(url, destPath, { validate: kind });
|
signal?.throwIfAborted();
|
||||||
|
const res = await downloadFile(url, destPath, { validate: kind, signal });
|
||||||
if (res.ok) return true;
|
if (res.ok) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -33,18 +35,26 @@ export async function tryDownloadCandidates(
|
|||||||
export async function downloadFile(
|
export async function downloadFile(
|
||||||
url: string,
|
url: string,
|
||||||
destPath: string,
|
destPath: string,
|
||||||
options?: { maxRetries?: number; validate?: "swf" | "png" },
|
options?: {
|
||||||
|
maxRetries?: number;
|
||||||
|
validate?: "swf" | "png";
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
): Promise<{ ok: boolean; size: number }> {
|
): Promise<{ ok: boolean; size: number }> {
|
||||||
const maxRetries = options?.maxRetries ?? 3;
|
const maxRetries = options?.maxRetries ?? 3;
|
||||||
const baseDelay = 1000;
|
const baseDelay = 1000;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
options?.signal?.throwIfAborted();
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||||
}
|
}
|
||||||
|
const timeout = AbortSignal.timeout(15000);
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: options?.signal
|
||||||
|
? AbortSignal.any([options.signal, timeout])
|
||||||
|
: timeout,
|
||||||
headers: browserHeaders("image", {
|
headers: browserHeaders("image", {
|
||||||
Accept: "image/png,image/*,*/*;q=0.8",
|
Accept: "image/png,image/*,*/*;q=0.8",
|
||||||
}),
|
}),
|
||||||
@@ -82,6 +92,7 @@ export async function downloadFile(
|
|||||||
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
||||||
return { ok: true, size: buffer.length };
|
return { ok: true, size: buffer.length };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (options?.signal?.aborted) throw err;
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[import-download] Download error ${url}:`,
|
`[import-download] Download error ${url}:`,
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ function nitroPathFor(lib: string): string {
|
|||||||
export async function importSinglePet(params: {
|
export async function importSinglePet(params: {
|
||||||
lib: string;
|
lib: string;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<ImportPetResult> {
|
}): Promise<ImportPetResult> {
|
||||||
const { lib, onProgress } = params;
|
const { lib, onProgress, signal } = params;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
await ensurePetDirs();
|
await ensurePetDirs();
|
||||||
|
|
||||||
@@ -66,7 +68,10 @@ export async function importSinglePet(params: {
|
|||||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
const dl = await downloadFile(swfUrl, swfPath, {
|
||||||
|
validate: "swf",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (!dl.ok)
|
if (!dl.ok)
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -76,6 +81,7 @@ export async function importSinglePet(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
|
signal?.throwIfAborted();
|
||||||
try {
|
try {
|
||||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||||
const result = convertSwfToNitro(swfBuffer, lib);
|
const result = convertSwfToNitro(swfBuffer, lib);
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ export interface RepairEvent {
|
|||||||
|
|
||||||
export async function repairMissingIcons(
|
export async function repairMissingIcons(
|
||||||
onEvent?: (evt: RepairEvent) => void,
|
onEvent?: (evt: RepairEvent) => void,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||||
|
options?.signal?.throwIfAborted();
|
||||||
let succeeded = 0;
|
let succeeded = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -204,6 +206,7 @@ export async function repairMissingIcons(
|
|||||||
onEvent?.({ type: "started", total });
|
onEvent?.({ type: "started", total });
|
||||||
|
|
||||||
const processItem = async (classname: string) => {
|
const processItem = async (classname: string) => {
|
||||||
|
options?.signal?.throwIfAborted();
|
||||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||||
// .png icon files to repair. Skip them.
|
// .png icon files to repair. Skip them.
|
||||||
if (dbSkipSet.has(classname)) {
|
if (dbSkipSet.has(classname)) {
|
||||||
@@ -312,7 +315,7 @@ export async function repairMissingIcons(
|
|||||||
targets.iconDir,
|
targets.iconDir,
|
||||||
fileName,
|
fileName,
|
||||||
),
|
),
|
||||||
{ maxRetries: 1, validate: "png" },
|
{ maxRetries: 1, validate: "png", signal: options?.signal },
|
||||||
);
|
);
|
||||||
if (dl.ok) {
|
if (dl.ok) {
|
||||||
const icon = await fs.readFile(
|
const icon = await fs.readFile(
|
||||||
@@ -350,7 +353,7 @@ export async function repairMissingIcons(
|
|||||||
const dl = await downloadFile(
|
const dl = await downloadFile(
|
||||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||||
nitroTmp,
|
nitroTmp,
|
||||||
{ maxRetries: 1 },
|
{ maxRetries: 1, signal: options?.signal },
|
||||||
);
|
);
|
||||||
if (dl.ok) {
|
if (dl.ok) {
|
||||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||||
@@ -390,6 +393,7 @@ export async function repairMissingIcons(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||||
|
options?.signal?.throwIfAborted();
|
||||||
const classname = item.item_name;
|
const classname = item.item_name;
|
||||||
const result = await processItem(classname);
|
const result = await processItem(classname);
|
||||||
if (result.status === "skipped") {
|
if (result.status === "skipped") {
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ export interface RepairNitroEvent {
|
|||||||
|
|
||||||
export async function repairMissingNitros(
|
export async function repairMissingNitros(
|
||||||
onEvent?: (evt: RepairNitroEvent) => void,
|
onEvent?: (evt: RepairNitroEvent) => void,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||||
|
options?.signal?.throwIfAborted();
|
||||||
let succeeded = 0;
|
let succeeded = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -201,6 +203,7 @@ export async function repairMissingNitros(
|
|||||||
onEvent?.({ type: "started", total });
|
onEvent?.({ type: "started", total });
|
||||||
|
|
||||||
const processItem = async (classname: string) => {
|
const processItem = async (classname: string) => {
|
||||||
|
options?.signal?.throwIfAborted();
|
||||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||||
// furni .nitro bundles to repair. Skip them.
|
// furni .nitro bundles to repair. Skip them.
|
||||||
if (skipSet.has(classname)) {
|
if (skipSet.has(classname)) {
|
||||||
@@ -276,7 +279,7 @@ export async function repairMissingNitros(
|
|||||||
const dl = await downloadFile(
|
const dl = await downloadFile(
|
||||||
`${source.nitroBaseUrl}/${nitroName}`,
|
`${source.nitroBaseUrl}/${nitroName}`,
|
||||||
tmpPath,
|
tmpPath,
|
||||||
{ maxRetries: 1 },
|
{ maxRetries: 1, signal: options?.signal },
|
||||||
);
|
);
|
||||||
if (dl.ok) {
|
if (dl.ok) {
|
||||||
// Validate it is a real Nitro bundle before writing it
|
// Validate it is a real Nitro bundle before writing it
|
||||||
@@ -315,6 +318,7 @@ export async function repairMissingNitros(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||||
|
options?.signal?.throwIfAborted();
|
||||||
const classname = item.item_name;
|
const classname = item.item_name;
|
||||||
const result = await processItem(classname);
|
const result = await processItem(classname);
|
||||||
if (result.status === "skipped") {
|
if (result.status === "skipped") {
|
||||||
@@ -384,13 +388,14 @@ export async function repairMissingNitros(
|
|||||||
*/
|
*/
|
||||||
export async function addSize32AllNitros(
|
export async function addSize32AllNitros(
|
||||||
onEvent?: (evt: RepairNitroEvent) => void,
|
onEvent?: (evt: RepairNitroEvent) => void,
|
||||||
opts?: { concurrency?: number },
|
opts?: { concurrency?: number; signal?: AbortSignal },
|
||||||
): Promise<{
|
): Promise<{
|
||||||
succeeded: number;
|
succeeded: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
existing: number;
|
existing: number;
|
||||||
}> {
|
}> {
|
||||||
|
opts?.signal?.throwIfAborted();
|
||||||
let succeeded = 0;
|
let succeeded = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -442,6 +447,7 @@ export async function addSize32AllNitros(
|
|||||||
[...fileNames],
|
[...fileNames],
|
||||||
opts?.concurrency ?? 12,
|
opts?.concurrency ?? 12,
|
||||||
async (fileName, i) => {
|
async (fileName, i) => {
|
||||||
|
opts?.signal?.throwIfAborted();
|
||||||
let buf: Buffer | null = null;
|
let buf: Buffer | null = null;
|
||||||
for (const dir of nitroDirs) {
|
for (const dir of nitroDirs) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
nitroBuffer: Buffer;
|
nitroBuffer: Buffer;
|
||||||
iconBuffer?: Buffer | null;
|
iconBuffer?: Buffer | null;
|
||||||
generateSql?: boolean;
|
generateSql?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<UploadResult> {
|
}): Promise<UploadResult> {
|
||||||
const {
|
const {
|
||||||
classname,
|
classname,
|
||||||
@@ -250,7 +251,9 @@ export async function uploadSingleFurni(params: {
|
|||||||
nitroBuffer,
|
nitroBuffer,
|
||||||
iconBuffer,
|
iconBuffer,
|
||||||
generateSql = false,
|
generateSql = false,
|
||||||
|
signal,
|
||||||
} = params;
|
} = params;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||||
@@ -280,6 +283,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureDirectories();
|
await ensureDirectories();
|
||||||
|
signal?.throwIfAborted();
|
||||||
const assetTargets = await getFurniAssetWriteTargets();
|
const assetTargets = await getFurniAssetWriteTargets();
|
||||||
const { iconDir, nitroDir } = assetTargets;
|
const { iconDir, nitroDir } = assetTargets;
|
||||||
|
|
||||||
@@ -297,6 +301,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
await fs.writeFile(nitroPath, nitroBuffer);
|
await fs.writeFile(nitroPath, nitroBuffer);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
let iconFileName: string | null = null;
|
let iconFileName: string | null = null;
|
||||||
let iconPath: string | null = null;
|
let iconPath: string | null = null;
|
||||||
@@ -307,6 +312,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mirroredPaths: string[] = [];
|
const mirroredPaths: string[] = [];
|
||||||
|
signal?.throwIfAborted();
|
||||||
await mirrorUploadedAsset(
|
await mirrorUploadedAsset(
|
||||||
nitroPath,
|
nitroPath,
|
||||||
nitroFileName,
|
nitroFileName,
|
||||||
@@ -346,6 +352,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||||
|
|
||||||
let newId: number;
|
let newId: number;
|
||||||
|
signal?.throwIfAborted();
|
||||||
try {
|
try {
|
||||||
newId = await allocateItemsBaseId(async (nextId) => {
|
newId = await allocateItemsBaseId(async (nextId) => {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
@@ -372,6 +379,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const furniEntry = buildFurniEntry({
|
const furniEntry = buildFurniEntry({
|
||||||
id: newId,
|
id: newId,
|
||||||
classname,
|
classname,
|
||||||
@@ -390,6 +398,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
|
|
||||||
let catalogItemId: number | null = null;
|
let catalogItemId: number | null = null;
|
||||||
try {
|
try {
|
||||||
|
signal?.throwIfAborted();
|
||||||
// Skip when the item already has a catalog row — re-uploads must never
|
// Skip when the item already has a catalog row — re-uploads must never
|
||||||
// create a second row for the same item_ids.
|
// create a second row for the same item_ids.
|
||||||
const [existingCatalog] = (await db.execute(sql`
|
const [existingCatalog] = (await db.execute(sql`
|
||||||
|
|||||||
@@ -3368,6 +3368,19 @@
|
|||||||
"api-keys": "API keys",
|
"api-keys": "API keys",
|
||||||
"banners": "Radio banners",
|
"banners": "Radio banners",
|
||||||
"embed": "Embed tools"
|
"embed": "Embed tools"
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"audit": "Asset audit",
|
||||||
|
"badges": "Badge Studio",
|
||||||
|
"clone": "Clone furniture",
|
||||||
|
"clothing": "Clothing Studio",
|
||||||
|
"effects": "Effect Studio",
|
||||||
|
"furni": "Furniture Studio",
|
||||||
|
"maintenance": "Furniture maintenance",
|
||||||
|
"pets": "Pet Studio",
|
||||||
|
"repair-icons": "Repair asset icons",
|
||||||
|
"sync": "Synchronize assets",
|
||||||
|
"upload": "Upload furniture"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
|
|||||||
@@ -3368,6 +3368,19 @@
|
|||||||
"api-keys": "Chiavi API",
|
"api-keys": "Chiavi API",
|
||||||
"banners": "Banner radio",
|
"banners": "Banner radio",
|
||||||
"embed": "Strumenti di incorporamento"
|
"embed": "Strumenti di incorporamento"
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"audit": "Audit degli asset",
|
||||||
|
"badges": "Studio badge",
|
||||||
|
"clone": "Clona furni",
|
||||||
|
"clothing": "Studio abbigliamento",
|
||||||
|
"effects": "Studio effetti",
|
||||||
|
"furni": "Studio furni",
|
||||||
|
"maintenance": "Manutenzione furni",
|
||||||
|
"pets": "Studio pet",
|
||||||
|
"repair-icons": "Ripara icone asset",
|
||||||
|
"sync": "Sincronizza asset",
|
||||||
|
"upload": "Carica furni"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
|
|||||||
Reference in new issue
Block a user