feat(housekeeping): integrate studio operations
This commit is contained in:
1 parent
020c06f172
commit
2e95a106e0
36 files changed
+2296
-60
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,
|
||||
type HousekeepingDomainManifest,
|
||||
} 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 = {
|
||||
id: "hotel",
|
||||
@@ -21,8 +24,8 @@ export const hotelManifest = {
|
||||
PERMS.PAGES_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
),
|
||||
routes: HOTEL_PRIMARY_ROUTES,
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
routes: HOTEL_ROUTES,
|
||||
searchProviders: HOTEL_SEARCH_PROVIDERS,
|
||||
inboxSources: HOTEL_INBOX_SOURCES,
|
||||
widgets: HOTEL_WIDGETS,
|
||||
} 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 { renderHotelRoomFurniPage } from "./pages/room-furni";
|
||||
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(
|
||||
routeId: HotelPrimaryRouteId,
|
||||
routeId: HotelRouteId,
|
||||
): HousekeepingRouteHandler["render"] {
|
||||
if (routeId.startsWith("hotel.studio.")) return renderHotelStudioPage;
|
||||
if (routeId === "hotel.rooms") return renderHotelRoomsPage;
|
||||
if (routeId === "hotel.room-detail") return renderHotelRoomDetailPage;
|
||||
if (routeId === "hotel.room-furni") return renderHotelRoomFurniPage;
|
||||
@@ -18,7 +20,7 @@ function rendererFor(
|
||||
|
||||
export const HOTEL_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze(
|
||||
HOTEL_PRIMARY_ROUTE_IDS.map((routeId) =>
|
||||
HOTEL_ROUTE_IDS.map((routeId) =>
|
||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||
),
|
||||
);
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
HOTEL_ASSET_CANONICAL_ROUTES,
|
||||
HOTEL_PRIMARY_ROUTE_IDS,
|
||||
HOTEL_PRIMARY_ROUTES,
|
||||
HOTEL_ROUTE_IDS,
|
||||
HOTEL_ROUTES,
|
||||
HOTEL_STUDIO_ROUTE_IDS,
|
||||
HOTEL_STUDIO_ROUTES,
|
||||
hotelRouteGroup,
|
||||
hotelStudioOperationKind,
|
||||
} from "./routes";
|
||||
|
||||
const expected = [
|
||||
@@ -74,11 +79,47 @@ describe("Hotel primary routes", () => {
|
||||
});
|
||||
|
||||
it("keeps manifest routes and handlers in exact equality", () => {
|
||||
expect(hotelManifest.routes).toBe(HOTEL_PRIMARY_ROUTES);
|
||||
expect(hotelManifest.routes).toBe(HOTEL_ROUTES);
|
||||
expect(
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
||||
handler.routeId.startsWith("hotel."),
|
||||
).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 { ECONOMY_COMMANDS } from "../../domains/economy/commands/economy-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 { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
||||
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
||||
@@ -46,6 +47,7 @@ const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...CONTENT_COMMANDS,
|
||||
...ECONOMY_COMMANDS,
|
||||
...HOTEL_COMMANDS,
|
||||
...STUDIO_COMMANDS,
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_COMMANDS,
|
||||
...SUPPORT_COMMANDS,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "../domains/content/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 { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
@@ -23,6 +23,10 @@ const SERVER_CAPABILITY_CONTEXT =
|
||||
const PERMISSIONS_ADAPTER = "src/lib/permissions";
|
||||
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
@@ -765,6 +794,16 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"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",
|
||||
new Set([
|
||||
@@ -790,6 +829,10 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"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",
|
||||
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-furni",
|
||||
"src/features/housekeeping/domains/hotel/pages/rooms",
|
||||
"src/features/housekeeping/domains/hotel/pages/studio",
|
||||
"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/economy/commands/economy-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/moderation-commands",
|
||||
"src/features/housekeeping/domains/people/commands/support-commands",
|
||||
@@ -1484,7 +1529,7 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "hotel")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(HOTEL_PRIMARY_ROUTE_IDS);
|
||||
).toEqual(HOTEL_ROUTE_IDS);
|
||||
expect(
|
||||
registry.domains
|
||||
.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.banners": "HK::hotel-radio-banners",
|
||||
"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.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_SEARCH_PROVIDERS } from "../domains/economy/search";
|
||||
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_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||
@@ -375,7 +378,7 @@ describe("housekeeping registry", () => {
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_ROUTES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_PRIMARY_ROUTES
|
||||
? HOTEL_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
@@ -387,7 +390,9 @@ describe("housekeeping registry", () => {
|
||||
? CONTENT_SEARCH_PROVIDERS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_SEARCH_PROVIDERS
|
||||
: [],
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_SEARCH_PROVIDERS
|
||||
: [],
|
||||
);
|
||||
expect(actual.inboxSources).toEqual(
|
||||
expected.id === "people"
|
||||
@@ -396,7 +401,9 @@ describe("housekeeping registry", () => {
|
||||
? CONTENT_INBOX_SOURCES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_INBOX_SOURCES
|
||||
: [],
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_INBOX_SOURCES
|
||||
: [],
|
||||
);
|
||||
expect(actual.widgets).toEqual(
|
||||
expected.id === "people"
|
||||
@@ -405,7 +412,9 @@ describe("housekeeping registry", () => {
|
||||
? CONTENT_WIDGETS
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_WIDGETS
|
||||
: [],
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_WIDGETS
|
||||
: [],
|
||||
);
|
||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "./domains/content/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 { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||
@@ -24,7 +24,7 @@ describe("housekeeping route handlers", () => {
|
||||
...PEOPLE_ROUTE_IDS,
|
||||
...CONTENT_ROUTE_IDS,
|
||||
...ECONOMY_ROUTE_IDS,
|
||||
...HOTEL_PRIMARY_ROUTE_IDS,
|
||||
...HOTEL_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -134,13 +134,16 @@ async function fetchWithFallback(url: string): Promise<string> {
|
||||
export async function fetchSourceFurnidata(
|
||||
url: string,
|
||||
now = Date.now(),
|
||||
signal?: AbortSignal,
|
||||
): Promise<SourceFurni[]> {
|
||||
signal?.throwIfAborted();
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
let body: string;
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(30000);
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
headers: browserHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -159,7 +162,8 @@ export async function fetchSourceFurnidata(
|
||||
} else {
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
if (body.trimStart().startsWith("<")) {
|
||||
@@ -243,11 +247,13 @@ export async function cloneSingleFurni(params: {
|
||||
source: CloneSource;
|
||||
entry: SourceFurni;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Skip the per-item FurnitureData.json write and return the entry instead,
|
||||
* so batch callers can append everything in one lock + write cycle. */
|
||||
deferFurniData?: boolean;
|
||||
}): Promise<CloneResult> {
|
||||
const { source, entry, onProgress, deferFurniData } = params;
|
||||
const { source, entry, onProgress, signal, deferFurniData } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { classname, itemType } = entry;
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -287,6 +293,7 @@ export async function cloneSingleFurni(params: {
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${classname}.nitro`,
|
||||
@@ -300,6 +307,7 @@ export async function cloneSingleFurni(params: {
|
||||
nitroPath,
|
||||
{
|
||||
maxRetries: 2,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!dl.ok) {
|
||||
@@ -334,6 +342,7 @@ export async function cloneSingleFurni(params: {
|
||||
{
|
||||
maxRetries: 1,
|
||||
validate: "png",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!iconDl.ok) {
|
||||
@@ -366,6 +375,7 @@ export async function cloneSingleFurni(params: {
|
||||
}
|
||||
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
const stackHeight =
|
||||
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.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
|
||||
// ids, which would silently break the emulator's catalog offer lookup.
|
||||
onProgress?.("writing_furnidata");
|
||||
signal?.throwIfAborted();
|
||||
const furniDataEntry = {
|
||||
...entry,
|
||||
id: newId,
|
||||
|
||||
@@ -89,8 +89,10 @@ export async function importClothingSet(params: {
|
||||
setType: string;
|
||||
setId: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportClothingSetResult> {
|
||||
const { setType, setId, onProgress } = params;
|
||||
const { setType, setId, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
const [officialFd, officialLibs] = await Promise.all([
|
||||
@@ -136,12 +138,17 @@ export async function importClothingSet(params: {
|
||||
let hardFail = false;
|
||||
|
||||
for (const libId of requiredLibIds) {
|
||||
signal?.throwIfAborted();
|
||||
const officialLib = officialLibs.find((l) => l.id === libId);
|
||||
if (!officialLib) continue;
|
||||
|
||||
if (!localLibIds.has(libId)) {
|
||||
onProgress?.(`importing library ${libId}`);
|
||||
const result = await importSingleFigure({ lib: libId, onProgress });
|
||||
const result = await importSingleFigure({
|
||||
lib: libId,
|
||||
onProgress,
|
||||
signal,
|
||||
});
|
||||
if (!result.ok) {
|
||||
hardFail = true;
|
||||
warnings.push(
|
||||
@@ -169,6 +176,7 @@ export async function importClothingSet(params: {
|
||||
}
|
||||
|
||||
const mappedSet = mapOfficialSet(set);
|
||||
signal?.throwIfAborted();
|
||||
await mergeSet(setType, st.paletteId, mappedSet as never);
|
||||
|
||||
if (palette) {
|
||||
|
||||
@@ -76,8 +76,10 @@ export async function importSingleEffect(params: {
|
||||
type: string;
|
||||
revision: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportEffectResult> {
|
||||
const { id, lib, type, revision, onProgress } = params;
|
||||
const { id, lib, type, revision, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
await ensureEffectDirs();
|
||||
|
||||
@@ -100,7 +102,10 @@ export async function importSingleEffect(params: {
|
||||
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)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -111,6 +116,7 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Convert SWF → Nitro
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
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)
|
||||
onProgress?.("writing_map");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const entry: EffectMapEntry = { id, lib, type, revision };
|
||||
await mergeEffect(entry);
|
||||
|
||||
@@ -78,8 +78,10 @@ async function nitroPathFor(lib: string): Promise<string> {
|
||||
export async function importSingleFigure(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportFigureResult> {
|
||||
const { lib, onProgress } = params;
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
await ensureFigureDirs();
|
||||
|
||||
@@ -99,7 +101,10 @@ export async function importSingleFigure(params: {
|
||||
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)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -109,6 +114,7 @@ export async function importSingleFigure(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -403,10 +403,13 @@ async function syncDirMissingFiles(
|
||||
* so all imported furniture stays renderable in-game even when a mirror
|
||||
* write was skipped.
|
||||
*/
|
||||
export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
export async function syncAssetsToGamedataBundle(
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
copiedNitros: string[];
|
||||
copiedIcons: string[];
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
if (process.platform === "win32")
|
||||
return { copiedNitros: [], copiedIcons: [] };
|
||||
|
||||
@@ -423,6 +426,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
"bundled/furniture",
|
||||
);
|
||||
if (nitroDir !== nitroTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
);
|
||||
@@ -434,6 +438,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
"icons",
|
||||
);
|
||||
if (iconDir !== iconTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
||||
entry.endsWith("_icon.png"),
|
||||
);
|
||||
@@ -508,6 +513,7 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite?: boolean;
|
||||
updateExisting?: boolean;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||
sourceSwfBaseUrl?: string;
|
||||
/** Per-source nitro bundle + icon base URL. */
|
||||
@@ -525,10 +531,12 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite,
|
||||
updateExisting,
|
||||
onProgress,
|
||||
signal,
|
||||
sourceSwfBaseUrl,
|
||||
nitroBaseUrl: sourceNitroBaseUrl,
|
||||
iconBaseUrl: sourceIconBaseUrl,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 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 ──────────────────────────────
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
if (isUpdate) {
|
||||
// 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
|
||||
// when no color-specific icon exists).
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const iconColorAware = classname.replace(/\*/g, "_");
|
||||
|
||||
@@ -701,8 +711,8 @@ export async function importSingleFurni(params: {
|
||||
// Download icon + SWF concurrently (independent assets). Each tries its
|
||||
// candidate URLs in order until one succeeds.
|
||||
const [iconOkResult, swfOk] = await Promise.all([
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png"),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf"),
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
|
||||
]);
|
||||
let iconOk = iconOkResult;
|
||||
|
||||
@@ -715,7 +725,10 @@ export async function importSingleFurni(params: {
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -744,6 +757,7 @@ export async function importSingleFurni(params: {
|
||||
|
||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
let conversionResult: ConversionResult | null = null;
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
|
||||
@@ -635,14 +635,21 @@ export interface FixAllResult {
|
||||
|
||||
/** Run every repair in a sensible order, then return before/after health. */
|
||||
export async function fixEverything(
|
||||
opts: { dedupePages?: boolean } = {},
|
||||
opts: { dedupePages?: boolean; signal?: AbortSignal } = {},
|
||||
): Promise<FixAllResult> {
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthBefore = await getFurniHealth();
|
||||
opts.signal?.throwIfAborted();
|
||||
const sprite = await fixSpriteIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const offers = await fixCatalogOffers();
|
||||
opts.signal?.throwIfAborted();
|
||||
const reconcile = await reconcileIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const dedup = await removeDuplicates(opts.dedupePages ?? true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const align = await forceItemsBaseIdsToFurnidata(true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthAfter = await getFurniHealth();
|
||||
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
|
||||
}
|
||||
@@ -17,7 +17,9 @@ export async function writeBadgeToExternalTexts(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (!gamedataRoot) {
|
||||
return {
|
||||
@@ -35,6 +37,7 @@ export async function writeBadgeToExternalTexts(
|
||||
const raw = await fs.readFile(extPath, "utf-8");
|
||||
texts = JSON.parse(raw) as Record<string, string>;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
// File not present yet (fresh deployment) — start with an empty object.
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn(
|
||||
@@ -49,6 +52,7 @@ export async function writeBadgeToExternalTexts(
|
||||
}
|
||||
texts = {};
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return {
|
||||
@@ -63,9 +67,11 @@ export async function writeBadgeToExternalTexts(
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
||||
signal?.throwIfAborted();
|
||||
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
@@ -83,7 +89,9 @@ export async function upsertWebsiteBadge(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
@@ -107,21 +115,28 @@ export async function upsertWebsiteBadge(
|
||||
* Single import path: ExternalTexts.json + WebsiteBadges.
|
||||
* Callers should gate with ASSETS_IMPORT.
|
||||
*/
|
||||
export async function importBadgeSynced(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<ImportBadgeResult> {
|
||||
export async function importBadgeSynced(
|
||||
input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
const texts = await writeBadgeToExternalTexts(
|
||||
input.code,
|
||||
input.name,
|
||||
input.description,
|
||||
signal,
|
||||
);
|
||||
if (!texts.ok) return texts;
|
||||
|
||||
try {
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description);
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ export async function tryDownloadCandidates(
|
||||
urls: string[],
|
||||
destPath: string,
|
||||
kind: "swf" | "png",
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
@@ -33,18 +35,26 @@ export async function tryDownloadCandidates(
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
options?: { maxRetries?: number; validate?: "swf" | "png" },
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
validate?: "swf" | "png";
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{ ok: boolean; size: number }> {
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
options?.signal?.throwIfAborted();
|
||||
if (attempt > 0) {
|
||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||
}
|
||||
const timeout = AbortSignal.timeout(15000);
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
signal: options?.signal
|
||||
? AbortSignal.any([options.signal, timeout])
|
||||
: timeout,
|
||||
headers: browserHeaders("image", {
|
||||
Accept: "image/png,image/*,*/*;q=0.8",
|
||||
}),
|
||||
@@ -82,6 +92,7 @@ export async function downloadFile(
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
||||
return { ok: true, size: buffer.length };
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) throw err;
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(
|
||||
`[import-download] Download error ${url}:`,
|
||||
|
||||
@@ -45,8 +45,10 @@ function nitroPathFor(lib: string): string {
|
||||
export async function importSinglePet(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportPetResult> {
|
||||
const { lib, onProgress } = params;
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
await ensurePetDirs();
|
||||
|
||||
@@ -66,7 +68,10 @@ export async function importSinglePet(params: {
|
||||
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)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -76,6 +81,7 @@ export async function importSinglePet(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -104,7 +104,9 @@ export interface RepairEvent {
|
||||
|
||||
export async function repairMissingIcons(
|
||||
onEvent?: (evt: RepairEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -204,6 +206,7 @@ export async function repairMissingIcons(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// .png icon files to repair. Skip them.
|
||||
if (dbSkipSet.has(classname)) {
|
||||
@@ -312,7 +315,7 @@ export async function repairMissingIcons(
|
||||
targets.iconDir,
|
||||
fileName,
|
||||
),
|
||||
{ maxRetries: 1, validate: "png" },
|
||||
{ maxRetries: 1, validate: "png", signal: options?.signal },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = await fs.readFile(
|
||||
@@ -350,7 +353,7 @@ export async function repairMissingIcons(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||
nitroTmp,
|
||||
{ maxRetries: 1 },
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||
@@ -390,6 +393,7 @@ export async function repairMissingIcons(
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
|
||||
@@ -106,7 +106,9 @@ export interface RepairNitroEvent {
|
||||
|
||||
export async function repairMissingNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -201,6 +203,7 @@ export async function repairMissingNitros(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// furni .nitro bundles to repair. Skip them.
|
||||
if (skipSet.has(classname)) {
|
||||
@@ -276,7 +279,7 @@ export async function repairMissingNitros(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${nitroName}`,
|
||||
tmpPath,
|
||||
{ maxRetries: 1 },
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
);
|
||||
if (dl.ok) {
|
||||
// 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) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
@@ -384,13 +388,14 @@ export async function repairMissingNitros(
|
||||
*/
|
||||
export async function addSize32AllNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
opts?: { concurrency?: number },
|
||||
opts?: { concurrency?: number; signal?: AbortSignal },
|
||||
): Promise<{
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
existing: number;
|
||||
}> {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -442,6 +447,7 @@ export async function addSize32AllNitros(
|
||||
[...fileNames],
|
||||
opts?.concurrency ?? 12,
|
||||
async (fileName, i) => {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let buf: Buffer | null = null;
|
||||
for (const dir of nitroDirs) {
|
||||
try {
|
||||
|
||||
@@ -234,6 +234,7 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer: Buffer;
|
||||
iconBuffer?: Buffer | null;
|
||||
generateSql?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<UploadResult> {
|
||||
const {
|
||||
classname,
|
||||
@@ -250,7 +251,9 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer,
|
||||
iconBuffer,
|
||||
generateSql = false,
|
||||
signal,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||
@@ -280,6 +283,7 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
signal?.throwIfAborted();
|
||||
const assetTargets = await getFurniAssetWriteTargets();
|
||||
const { iconDir, nitroDir } = assetTargets;
|
||||
|
||||
@@ -297,6 +301,7 @@ export async function uploadSingleFurni(params: {
|
||||
};
|
||||
}
|
||||
await fs.writeFile(nitroPath, nitroBuffer);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
let iconFileName: string | null = null;
|
||||
let iconPath: string | null = null;
|
||||
@@ -307,6 +312,7 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
const mirroredPaths: string[] = [];
|
||||
signal?.throwIfAborted();
|
||||
await mirrorUploadedAsset(
|
||||
nitroPath,
|
||||
nitroFileName,
|
||||
@@ -346,6 +352,7 @@ export async function uploadSingleFurni(params: {
|
||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||
|
||||
let newId: number;
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
@@ -372,6 +379,7 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const furniEntry = buildFurniEntry({
|
||||
id: newId,
|
||||
classname,
|
||||
@@ -390,6 +398,7 @@ export async function uploadSingleFurni(params: {
|
||||
|
||||
let catalogItemId: number | null = null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
// Skip when the item already has a catalog row — re-uploads must never
|
||||
// create a second row for the same item_ids.
|
||||
const [existingCatalog] = (await db.execute(sql`
|
||||
|
||||
@@ -3368,6 +3368,19 @@
|
||||
"api-keys": "API keys",
|
||||
"banners": "Radio banners",
|
||||
"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": {
|
||||
|
||||
@@ -3368,6 +3368,19 @@
|
||||
"api-keys": "Chiavi API",
|
||||
"banners": "Banner radio",
|
||||
"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": {
|
||||
|
||||
Reference in new issue
Block a user